Every networked application you write depends on DNS. When your code calls getaddrinfo() in C, dns.resolve() in Node.js, or socket.getaddrinfo() in Python, it kicks off a protocol exchange that has been largely unchanged since 1987. The binary packet format, the resolution hierarchy, the response codes — they all trace back to RFC 1035.
I wrote this guide because understanding DNS at the protocol level changed the way I debug network issues and build tooling. If you have ever stared at a dig output wondering what flags: qr rd ra means, or wondered why a query returns SERVFAIL instead of an answer, this article is for you.
I will walk through the full resolution chain, dissect a DNS packet byte by byte, explain every flag and response code, and cover the differences between system resolvers and direct queries. By the end, you will understand enough to read a raw DNS packet capture — or build your own resolver from scratch.
The Full Resolution Chain
A DNS query involves multiple actors passing messages up and down a hierarchy. Here is what happens when your application resolves example.com:
Step 1: The Stub Resolver
Your application does not query DNS servers directly. It calls into the operating system's stub resolver — a minimal DNS client embedded in your OS's C library. The stub resolver checks the local cache, then forwards the query to a recursive resolver configured in /etc/resolv.conf (Linux/macOS) or the network adapter settings (Windows).
The stub resolver has almost no logic. It sends a query with the RD (Recursion Desired) flag set and expects a full answer back.
Step 2: The Recursive Resolver
The recursive resolver does the heavy lifting. This is typically operated by your ISP, your corporate network, or a public service like Cloudflare (1.1.1.1), Google Public DNS (8.8.8.8), or Quad9 (9.9.9.9). When it receives a query it cannot answer from cache, it walks the DNS tree from the root down.
Step 3: Root Servers
The recursive resolver starts by querying one of the 13 root server clusters. These are not 13 individual machines — each cluster is an anycast group of hundreds of servers distributed globally. They are named a.root-servers.net through m.root-servers.net and are operated by organizations like ICANN, Verisign, the US Army, and several universities.
The root server does not know the answer for example.com. It responds with a referral: the NS records and glue A/AAAA records for the .com TLD nameservers.
Step 4: TLD Servers
The recursive resolver follows the referral and queries a .com TLD server (operated by Verisign). The TLD server does not know the full answer either — it responds with another referral pointing to the authoritative nameservers for example.com.
Step 5: Authoritative Servers
Finally, the recursive resolver queries the authoritative server for example.com. This server holds the actual zone file and returns the definitive answer — say, an A record pointing to 93.184.215.14.
The Answer Returns
The recursive resolver caches the answer (respecting the TTL value in the response) and sends it back to the stub resolver, which caches it locally and returns it to your application. The entire chain — from stub to root to TLD to authoritative and back — typically completes in under 100 milliseconds.
Subsequent queries for the same name skip most of these steps because each layer caches the result. For a deeper look at how caching and TTL affect DNS updates, see my article on DNS propagation.
# Trace the full resolution chain with dig +trace
dig +trace example.com A
# Output shows each step:
# . root servers (NS referral)
# com. TLD servers (NS referral)
# example.com. authoritative answer (A record)
DNS Packet Anatomy
Every DNS message — query and response — uses the same binary format defined in RFC 1035. The packet consists of five sections: Header, Question, Answer, Authority, and Additional.
Header Section (12 Bytes)
The header is always exactly 12 bytes and is present in every DNS message. Here is the layout:
1 1 1 1 1 1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ID |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|QR| Opcode |AA|TC|RD|RA| Z | RCODE |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QDCOUNT |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ANCOUNT |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| NSCOUNT |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ARCOUNT |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
Each field:
| Field | Size | Purpose |
|---|---|---|
| ID | 16 bits | Transaction ID. The client generates a random value; the server copies it into the response so the client can match replies to queries. |
| QR | 1 bit | Query (0) or Response (1). |
| Opcode | 4 bits | Type of query. 0 = standard query (QUERY), 1 = inverse query (IQUERY, obsolete), 2 = server status (STATUS). |
| AA | 1 bit | Authoritative Answer. Set when the responding server is authoritative for the queried domain. |
| TC | 1 bit | Truncation. Set when the response was too large for the transport and was truncated. |
| RD | 1 bit | Recursion Desired. Set by the client to request recursive resolution. |
| RA | 1 bit | Recursion Available. Set by the server if it supports recursion. |
| Z | 3 bits | Reserved for future use (must be zero in classic DNS; one bit is used for DNSSEC AD/CD flags in later RFCs). |
| RCODE | 4 bits | Response code. 0 = no error, 3 = NXDOMAIN, etc. |
| QDCOUNT | 16 bits | Number of entries in the Question section (almost always 1). |
| ANCOUNT | 16 bits | Number of resource records in the Answer section. |
| NSCOUNT | 16 bits | Number of resource records in the Authority section. |
| ARCOUNT | 16 bits | Number of resource records in the Additional section. |
Question Section
The question section contains the name being queried. Each question entry has three fields:
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QNAME |
/ (variable) /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QTYPE |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QCLASS |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
QNAME is encoded as a sequence of length-prefixed labels. The domain example.com is encoded as:
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 |0| (root)
Each label starts with a byte indicating its length, followed by the ASCII characters. The sequence terminates with a zero-length label (byte 0x00) representing the root. This encoding is why domain labels are limited to 63 characters (the maximum value storable in 6 bits, since the top two bits are reserved for compression pointers) and total domain names cannot exceed 253 characters.
QTYPE is a 16-bit value indicating the record type: 1 for A, 28 for AAAA, 5 for CNAME, 15 for MX, and so on.
QCLASS is almost always 1 (IN for Internet). You will occasionally see 255 (ANY) in older tooling, but class values other than IN are effectively unused in practice.
Answer Section
The answer section contains resource records (RRs) that answer the question. Each RR has this format:
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| NAME |
/ (variable) /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| TYPE |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| CLASS |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| TTL |
| |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| RDLENGTH |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| RDATA |
/ (variable) /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
NAME uses the same label encoding as QNAME, but responses almost always use compression pointers to save space. A compression pointer is a two-byte sequence where the first two bits are 11, and the remaining 14 bits are an offset into the message where the name (or a suffix of it) was previously written.
TTL is a 32-bit unsigned integer specifying how many seconds the record can be cached. For more on how TTL works in practice, see What Is DNS TTL?.
RDATA is the record-type-specific data. For an A record, it is 4 bytes representing the IPv4 address. For an AAAA record, 16 bytes. For a CNAME, it is another domain name in label encoding.
Authority and Additional Sections
The Authority section contains NS records pointing to the authoritative nameservers for the domain. This is populated in referral responses (when a server says "I do not know the answer, but ask these servers").
The Additional section contains records that may be useful alongside the main answer. The most common use is glue records — A/AAAA records for the nameservers listed in the Authority section, so the resolver does not need a separate lookup to find their IP addresses. EDNS0 OPT pseudo-records also appear here.
Query Flags Deep Dive
The 16-bit flags field in the header controls the behavior of every DNS transaction. Here is what each flag means and when you will see it.
QR — Query or Response
The simplest flag. 0 means this message is a query; 1 means it is a response. Every response must have QR set to 1.
Opcode — Operation Type
Four bits specifying the kind of query. In practice, you will almost only see opcode 0 (QUERY). Opcode 4 (NOTIFY) is used in zone replication, and opcode 5 (UPDATE) is used for dynamic DNS. The rest are obsolete.
AA — Authoritative Answer
Set in responses when the server that generated the answer is authoritative for the domain. If you query a recursive resolver, this flag will typically be unset — the resolver is returning a cached answer, not an authoritative one. If you query the authoritative nameserver directly, you will see AA set.
# Query a recursive resolver — no AA flag
dig example.com A @8.8.8.8
# flags: qr rd ra
# Query the authoritative server directly — AA flag present
dig example.com A @a.iana-servers.net
# flags: qr aa rd
TC — Truncation
Set when the response was too large to fit in the transport's maximum message size and was truncated. When a client sees TC set, it should retry the query over TCP to get the full response. This is most commonly triggered by DNSSEC-signed responses that exceed the UDP buffer size.
RD — Recursion Desired
Set by the client to tell the server "please resolve this recursively." Stub resolvers always set this flag. When querying an authoritative server directly (e.g., with dig +norecurse), you leave RD unset to get a referral rather than a recursive answer.
RA — Recursion Available
Set by the server to indicate it supports recursive resolution. Authoritative-only servers will not set this flag. If you see RA unset in a response from your configured resolver, something is misconfigured.
RCODE — Response Code
The RCODE tells you the outcome of the query. I cover this in detail in the next section.
Record Types Developers Query Most
DNS supports dozens of record types, but as a developer you will work with a handful regularly:
| Type | Value | Purpose |
|---|---|---|
| A | 1 | Maps a domain to an IPv4 address |
| AAAA | 28 | Maps a domain to an IPv6 address |
| CNAME | 5 | Alias from one name to another |
| MX | 15 | Mail server for the domain (with priority) |
| TXT | 16 | Arbitrary text — used for SPF, DKIM, domain verification |
| NS | 2 | Delegated nameserver for a zone |
| SOA | 6 | Zone metadata — primary NS, admin email, serial, refresh timers |
| PTR | 12 | Reverse lookup — maps an IP address back to a domain name |
For a comprehensive guide covering each type with syntax examples and configuration advice, see Understanding DNS Record Types.
Response Codes (RCODE)
The RCODE field is 4 bits in the DNS header, giving 16 possible values. In practice, you will encounter five regularly.
NOERROR (0) — Success
The query completed successfully. An answer section is present (though it may be empty if the name exists but has no records of the requested type — this is called NODATA and is distinct from NXDOMAIN).
$ dig example.com A +short
93.184.215.14
# RCODE: NOERROR
FORMERR (1) — Format Error
The server could not interpret the query. This usually means the query packet was malformed. In practice, you see FORMERR when there is a bug in the DNS client library, when sending a query type the server does not understand, or when EDNS0 negotiation fails with an older server.
SERVFAIL (2) — Server Failure
The server tried to process the query but encountered an internal error. The most common causes are DNSSEC validation failures, unreachable authoritative nameservers, and broken delegation chains. SERVFAIL is the most frustrating error to debug because it is a catch-all — see my detailed breakdown in What Is SERVFAIL?.
# DNSSEC failure causing SERVFAIL
dig broken-dnssec.example A @8.8.8.8
# status: SERVFAIL
# Bypass DNSSEC validation to confirm
dig broken-dnssec.example A @8.8.8.8 +cd
# status: NOERROR (DNSSEC was the problem)
NXDOMAIN (3) — Non-Existent Domain
The authoritative server definitively states that the queried domain does not exist. This is an authoritative negative answer — the domain is not registered or has no zone configured. For detailed coverage, see What Is NXDOMAIN?.
Note the difference from NODATA: if example.com exists but has no MX record, you get NOERROR with an empty answer section. If doesnotexist.example.com is not in the zone at all, you get NXDOMAIN.
REFUSED (5) — Query Refused
The server refused to answer the query, usually due to access control. This happens when you query a recursive resolver that only serves its own network, or when an authoritative server is configured to reject queries for zones it does not host.
# Querying an ISP resolver from outside its network
dig example.com A @isp-resolver.example
# status: REFUSED
UDP vs TCP
DNS was designed in an era when every byte mattered. The protocol defaults to UDP on port 53 for performance — no connection setup overhead, no three-way handshake, just fire a packet and wait for the reply.
The 512-Byte Limit
The original RFC 1035 specification set a maximum UDP message size of 512 bytes. This was chosen to avoid IP fragmentation on virtually any network path. Any response exceeding 512 bytes would set the TC (Truncation) flag and require the client to retry over TCP.
512 bytes was adequate for decades, but DNSSEC changed the equation. A DNSSEC-signed response with RRSIG and DNSKEY records routinely exceeds 512 bytes.
EDNS0 — Extending the Limit
RFC 6891 introduced EDNS0 (Extension Mechanisms for DNS), which allows clients to advertise a larger UDP buffer size — typically 4096 bytes. The client adds an OPT pseudo-record in the Additional section announcing its buffer size:
# dig sends EDNS0 by default
dig example.com A
# The output includes:
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
Most modern resolvers support EDNS0, making the 512-byte limit largely historical. However, some middleboxes (firewalls, NAT devices) still interfere with large UDP DNS packets, which is why TCP fallback remains essential.
When TCP Takes Over
DNS uses TCP (also on port 53) in several scenarios:
- Truncated responses — When a UDP response sets the TC flag, the client retries over TCP to get the complete answer.
- Zone transfers — AXFR (full zone transfer) and IXFR (incremental zone transfer) always use TCP because they transfer the entire contents of a zone, which can be megabytes.
- Large queries — Some DNS implementations send queries over TCP preemptively when they expect a large response.
DNS over HTTPS and DNS over TLS
Modern encrypted DNS transports — DNS over HTTPS (DoH, RFC 8484) and DNS over TLS (DoT, RFC 7858) — use TCP-based connections by default. DoT operates on port 853, while DoH operates over standard HTTPS on port 443. These transports encrypt the DNS traffic to prevent eavesdropping and tampering, but the wire format of the DNS message inside the encrypted channel remains the same RFC 1035 binary format.
System Resolver vs Direct DNS Queries
This distinction trips up many developers. There are two fundamentally different ways to perform DNS lookups, and they do not behave the same way.
System Resolver (getaddrinfo / gethostbyname)
When you call getaddrinfo() in C, socket.getaddrinfo() in Python, or use any standard library HTTP client, you are going through the operating system's stub resolver. This code path:
- Reads
/etc/hostsfirst (on most systems). If the hostname is listed there, the resolver returns that IP without querying DNS at all. - Consults
/etc/nsswitch.conf(Linux) or equivalent to determine the lookup order (files dns myhostname). - Reads
/etc/resolv.confto find the configured recursive resolvers (nameserver entries). - Sends a DNS query to the configured resolver with the RD flag set.
- Respects the operating system's DNS cache (systemd-resolved, mDNSResponder on macOS, DNS Client service on Windows).
Most standard library functions in every language use the system resolver by default. In Node.js, dns.lookup() (used by http.get, fetch, and most HTTP clients) goes through getaddrinfo(). In Python, socket.getaddrinfo() and by extension urllib and requests do the same. In Go, the default resolver uses the system resolver unless you configure the pure-Go resolver.
Direct DNS Queries
Libraries like dns.resolve() in Node.js, dnspython in Python, and Net_DNS2 in PHP construct raw DNS packets and send them directly to nameservers. This approach:
- Bypasses
/etc/hostsentirely. - Bypasses the OS DNS cache.
- Lets you query specific nameservers (authoritative servers, alternative resolvers).
- Lets you query any record type (system resolvers typically only resolve A/AAAA).
- Gives you full access to the DNS response — flags, TTL, authority section, everything.
When Each Approach Matters
Use the system resolver when you want the same behavior a user's browser would get — respecting /etc/hosts overrides, the system cache, and the system's configured nameservers. This is what you want for standard HTTP requests.
Use direct queries when you are building DNS tooling, need to query a specific nameserver, need access to record types beyond A/AAAA, or need to inspect the raw DNS response. This is what tools like DNS Inspector use under the hood — they send direct queries to authoritative servers to show you exactly what each server returns.
# System resolver (uses OS config, /etc/hosts, cache)
getent hosts example.com
# Direct DNS query to a specific resolver
dig @8.8.8.8 example.com A
# Direct query to the authoritative server
dig @a.iana-servers.net example.com A +norecurse
Choose Your Path
If this article has made you curious about what happens inside the wire, I have written two series of hands-on guides.
Series A: Build a DNS Resolver from Scratch
Construct raw DNS packets, send them over UDP, and parse the binary responses. No libraries — just the RFC and a socket:
Series B: Production DNS Lookups with Libraries
Use battle-tested libraries for real-world DNS lookups — proper error handling, timeouts, and record parsing:
If you prefer working from the command line, my dig Command Guide covers everything from basic lookups to tracing the full resolution chain.
Putting It Into Practice
Understanding the DNS protocol is valuable on its own, but it becomes actionable when you can inspect real queries. The DNS Inspector lets you query any domain against multiple nameservers simultaneously and see the raw response — flags, TTL, authority records, and all. When you need to verify whether a DNS change has reached resolvers around the world, the Propagation Checker queries dozens of public resolvers globally and shows you exactly which ones have picked up the new value.
For related reading, see Understanding DNS Record Types for a full record-type reference, What Is DNSSEC? for how DNS responses are cryptographically signed, DNS Troubleshooting Tools Guide for a practical toolkit, and What Is DNS TTL? for how caching timers work.
