The Fundamentals: Domain Name System
Building a DNS server from scratch is a rite of passage for network engineers. It forces you to leave the comfort of text-based protocols like HTTP and JSON and dive into the world of bits, bytes, and packed binary structures.
This project is a Go implementation of a DNS server. We will implement the core of the Domain Name System protocol as defined in RFC 1035, handling everything from parsing raw UDP packets to constructing compliant responses.
1. The Phonebook of the Internet
At its simplest, DNS is a distributed database that maps human-readable hostnames (like example.com) to IP addresses (like 93.184.216.34).
While we take it for granted, the mechanism behind it is a fascinating study in efficiency and longevity. Unlike the verbose, text-heavy HTTP protocol we explored in HTTP from TCP, DNS was designed in the bandwidth-scarce 1980s. It uses a compact binary format over UDP to keep messages small and fast.
2. Resource Records
While we often think of DNS as a simple hostname-to-IP mapper, it is actually a flexible database capable of storing various types of data. These individual entries are called Resource Records (RRs).
When a client queries a DNS server, they don’t just ask “Where is google.com?”; they ask “Where is the IPv4 address for google.com?” or “Who handles email for google.com?”
The original set of records was defined in RFC 1035, but as the Internet evolved, new types were added in subsequent RFCs (like AAAA for IPv6, defined in RFC 3596). Today, the IANA DNS Parameters registry serves as the comprehensive source of truth for all assigned Record Types.
Here are the most common record types we will encounter:
| Type | ID | Description |
|---|---|---|
| A | 1 | Address Record. Maps a hostname to a 32-bit IPv4 address. |
| AAAA | 28 | IPv6 Address Record. Maps a hostname to a 128-bit IPv6 address. |
| CNAME | 5 | Canonical Name. An alias that points one domain to another (e.g., www.example.com -> example.com). |
| MX | 15 | Mail Exchange. Specifies the mail server responsible for accepting email messages on behalf of a domain. |
| NS | 2 | Name Server. Delegates a DNS zone to use the given authoritative name servers. |
| TXT | 16 | Text Record. Originally for human-readable text, now mostly used for verification (SPF, DKIM, site ownership). |
| SOA | 6 | Start of authority. Denotes which DNS server has authority over a given DNS zone |

3. Resource Record Structure
Regardless of their Type, all Resource Records share a common top-level binary format (RFC 1035 Section 3.2.1).
1 1 1 1 1 1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| |
/ /
/ NAME /
| (255 octets for names, 63 octets for labels) |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| TYPE |
| (2 octets) |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| CLASS |
| (2 octets) |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| TTL |
| (4 octets) |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| RDLENGTH |
| (2 octets) |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
/ RDATA /
/ (VARIABLE LENGTH DEFINED BY RDLENGTH) /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
- NAME: A variable-length sequence of labels representing the domain name (e.g.,
google.com). A label can be up to 63 bytes, and the full name is limited to 255 bytes (including the final null byte). - TYPE: A 16-bit integer specifying the record type (e.g.,
1for A,15for MX). - CLASS: A 16-bit integer.
- TTL: A 32-bit signed integer. The Time To Live specifies how long (in seconds) this record can be cached by a resolver.
- RDLENGTH: A 16-bit integer specifying the length of the
RDATAfield in bytes. - RDATA: The actual data describing the resource. The format of this field varies entirely depending on the
TYPEandCLASSof the record.
In Go, we can represent this generic structure like so:
type ResourceRecord struct {
Name string
Type uint16
Class uint16
TTL uint32
RdLength uint16
RData []byte
}
func NewResourceRecord(name string, t uint16, class uint16, ttl uint32, rdata []byte) *ResourceRecord {
return &ResourceRecord{
Name: name,
Type: t,
Class: class,
TTL: ttl,
RdLength: uint16(len(rdata)),
RData: rdata,
}
}
// Getters
func (rr *ResourceRecord) GetName() string { return rr.Name }
func (rr *ResourceRecord) GetType() uint16 { return rr.Type }
// Setters (recalculating RdLength automatically)
func (rr *ResourceRecord) SetData(data []byte) {
rr.RData = data
rr.RdLength = uint16(len(data))
}
// Pack (Serialization) - Simplified view
func (rr *ResourceRecord) Pack(buf *bytes.Buffer) error {
// Write Name (Labels)
// Write Type (binary.BigEndian)
// Write Class
// Write TTL
// Write RdLength
// Write RData
return nil
}
RDATA subformats
Additionally, standard RRs also have different RDATA formats; for example, CNAME is defined as
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ / CNAME / / (A <domain-name> which specifies the / / canonical or primary name for the owner.) / +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
While an SOA RR has an RDATA in the following format:
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ / MNAME / / (The <domain-name> of the name server that / / was the original or primary source of data / / for this zone.) / +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ / RNAME / / (A <domain-name> which specifies the mailbox/ / of the person responsible for this zone.) / +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | SERIAL | | (The unsigned 32 bit version number of the | | original copy of the zone.) | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | REFRESH | | (A 32 bit time interval before the zone | | should be refreshed.) | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | RETRY | | (A 32 bit time interval that should elapse | | before a failed refresh should be retried.)| +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | EXPIRE | | (A 32 bit time interval that specifies the | | upper limit on the time interval that can | | elapse before the zone is no longer | | authoritative.) | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | MINIMUM | | (The unsigned 32 bit minimum TTL field that | | should be exported with any RR from this | | zone.) | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
In case you’re interested in more RDATA format definitions (yes, there are more of them), you should refer to RFCs.
4. Messages
Just like in HTTP, “All communications inside of the domain protocol are carried in a single
format called a message.”
RFC 1035 explains that a top-level message is divided into 5 sections. These sections are Header, Question, Answer, Authority, and Additional sections.
+---------------------+ | Header | +---------------------+ | Question | the question for the name server +---------------------+ | Answer | RRs answering the question +---------------------+ | Authority | RRs pointing toward an authority +---------------------+ | Additional | RRs holding additional information +---------------------+
The header section of a DNS message is arguably the most important one, because it defines what other sections are present in the message. In addition to that, it also specifies whether a message is a query or a response!
4.1 Format of a Header section
The header section of a DNS message contains the following fields:
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 |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Field | Size | Description |
|---|---|---|
Packet Identifier (ID) | 16 bits | A random ID assigned to query packets. Response packets must reply with the same ID. |
Query/Response Indicator (QR) | 1 bit | 1 for a reply packet, 0 for a question packet. |
Operation Code (OPCODE) | 4 bits | Specifies the kind of query in a message.0: Standard Query (Most common).1: Inverse Query (Obsolete).2: Status Request (Server health).4: Notify (Zone change notification).5: Update (Dynamic DNS update). |
Authoritative Answer (AA) | 1 bit | 1 if the responding server “owns” the domain queried, i.e., it’s authoritative. |
Truncation (TC) | 1 bit | 1 if the message is larger than 512 bytes. |
Recursion Desired (RD) | 1 bit | Sender sets this to 1 if the server should recursively resolve this query, 0 otherwise. |
Recursion Available (RA) | 1 bit | Server sets this to 1 to indicate that recursion is available. |
Reserved (Z) | 3 bits | Used by DNSSEC queries. At inception, it was reserved for future use. |
Response Code (RCODE) | 4 bits | Response code indicating the status of the response.0: No Error.1: Format Error.2: Server Failure.3: Name Error (nxdomain).4: Not Implemented.5: Refused. |
Question Count (QDCOUNT) | 16 bits | Number of questions in the Question section. |
Answer Record Count (ANCOUNT) | 16 bits | Number of records in the Answer section. |
Authority Record Count (NSCOUNT) | 16 bits | Number of records in the Authority section. |
Additional Record Count (ARCOUNT) | 16 bits | Number of records in the Additional section. |
The header section is always 12 bytes long.
Integers are encoded in big-endian format.
4.1.1 Working with DNS Headers
Here is how we can implement the DNS Header in Go, including parsing raw bytes and serializing the structure back to bytes.
package dns
import (
"encoding/binary"
"fmt"
)
type Header struct {
ID uint16 // A random ID assigned to query packets. Response packets must reply with the same ID.
QR bool // 1 for a reply packet, 0 for a question packet.
Opcode uint8 // Specifies the kind of query in a message.
AA bool // 1 if the responding server "owns" the domain queried, i.e., it's authoritative.
TC bool // 1 if the message is larger than 512 bytes.
RD bool // Sender sets this to 1 if the server should recursively resolve this query, 0 otherwise.
RA bool // Server sets this to 1 to indicate that recursion is available.
Z uint8 // Used by DNSSEC queries. At inception, it was reserved for future use.
RCode uint8 // Response code indicating the status of the response.
QDCount uint16 // Number of questions in the Question section.
ANCount uint16 // Number of records in the Answer section.
NSCount uint16 // Number of records in the Authority section.
ARCount uint16 // Number of records in the Additional section.
}
// ToBytes serializes the header into a 12-byte slice.
func (h *Header) ToBytes() []byte {
buf := make([]byte, 12)
binary.BigEndian.PutUint16(buf[0:2], h.ID)
// Byte 2: QR(1) | Opcode(4) | AA(1) | TC(1) | RD(1)
var b2 uint8
if h.QR {
b2 |= 1 << 7
}
b2 |= (h.Opcode & 0x0F) << 3
if h.AA {
b2 |= 1 << 2
}
if h.TC {
b2 |= 1 << 1
}
if h.RD {
b2 |= 1
}
buf[2] = b2
// Byte 3: RA(1) | Z(3) | RCode(4)
var b3 uint8
if h.RA {
b3 |= 1 << 7
}
b3 |= (h.Z & 0x07) << 4
b3 |= h.RCode & 0x0F
buf[3] = b3
binary.BigEndian.PutUint16(buf[4:6], h.QDCount)
binary.BigEndian.PutUint16(buf[6:8], h.ANCount)
binary.BigEndian.PutUint16(buf[8:10], h.NSCount)
binary.BigEndian.PutUint16(buf[10:12], h.ARCount)
return buf
}
// NewHeader parses a 12-byte slice into a Header struct.
func NewHeader(buf []byte) (*Header, error) {
if len(buf) < 12 {
return nil, fmt.Errorf("header must be 12 bytes")
}
h := &Header{}
h.ID = binary.BigEndian.Uint16(buf[0:2])
// Byte 2
b2 := buf[2]
h.QR = (b2 >> 7) == 1
h.Opcode = (b2 >> 3) & 0x0F
h.AA = (b2>>2)&1 == 1
h.TC = (b2>>1)&1 == 1
h.RD = b2&1 == 1
// Byte 3
b3 := buf[3]
h.RA = (b3 >> 7) == 1
h.Z = (b3 >> 4) & 0x07
h.RCode = b3 & 0x0F
h.QDCount = binary.BigEndian.Uint16(buf[4:6])
h.ANCount = binary.BigEndian.Uint16(buf[6:8])
h.NSCount = binary.BigEndian.Uint16(buf[8:10])
h.ARCount = binary.BigEndian.Uint16(buf[10:12])
return h, nil
}
4.2 Format of a Question section
RFC 1035 Section 4.1.2 tells us that the question section is used to carry the “question” in most queries, i.e., the parameters that define what is being asked. The section
contains QDCOUNT (usually 1) entries, each of the following format:
1 1 1 1 1 1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| |
/ QNAME /
/ /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QTYPE |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QCLASS |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Field | Size | Description |
|---|---|---|
Name (QNAME) | Variable | A domain name, represented as a sequence of “labels”. |
Type (QTYPE) | 16 bits | The type of record (1 for an A record, 5 for a CNAME record, etc.). |
Class (QCLASS) | 16 bits | The class of the query (usually set to 1 for INternet). |
4.2.1 Domain Name Encoding
Domain names in DNS packets are encoded as a sequence of labels.
Labels are encoded as <length><content>, where <length> is a single byte that specifies the length of the label, and <content> is the actual content of the label. The sequence of labels is terminated by a null byte (\x00).
For example, google.com is encoded as:
\x06google\x03com\x00
In hex, this looks like: 06 67 6f 6f 67 6c 65 03 63 6f 6d 00.
\x06googleis the first label.\x06is the length byte (6).googleis the content (6 bytes).
\x03comis the second label.\x03is the length byte (3).comis the content (3 bytes).
\x00is the null byte that terminates the domain name.
4.2.2 Working with DNS Questions
Here is how we can implement the Question section in Go. We’ll define a Question struct and a helper Label struct to handle the variable-length domain name segments.
const (
MaxMessageSize = 512
HeaderSize = 12
MaxLabelSize = 63
)
type Question struct {
labels []Label
qtype uint16
class uint16
}
type Label struct {
length int
label string
}
// Bytes serializes the Question struct into a byte slice.
func (q Question) Bytes() ([]byte, error) {
buf := []byte{}
for _, label := range q.labels {
buf = append(buf, byte(label.length))
buf = append(buf, []byte(label.label)...)
}
buf = append(buf, 0) // Terminating null byte
buf = binary.BigEndian.AppendUint16(buf, q.qtype)
buf = binary.BigEndian.AppendUint16(buf, q.class)
return buf, nil
}
// ParseLabels extracts labels from the buffer, starting immediately after the header.
func ParseLabels(buf []byte) ([]Label, int, error) {
if len(buf) < HeaderSize {
return nil, 0, errors.New("Message does not have question section")
}
if buf[HeaderSize] == 0 {
return nil, 0, errors.New("Message has empty question section")
}
// Start parsing after the 12-byte header
offset := HeaderSize
labels := []Label{}
for {
if offset >= len(buf) {
return nil, 0, errors.New("Malformed question section")
}
// Read the length of the next label
length := int(buf[offset])
// A length of 0 indicates the end of the name
if length == 0 {
break
}
// Ensure the label content is within bounds
if offset+length >= len(buf) {
return nil, 0, errors.New("Malformed question section")
}
// Extract the label string
label := string(buf[offset+1 : offset+1+length])
labels = append(labels, Label{
length: length,
label: label,
})
// Move offset past the length byte and the label content
offset += length + 1
}
// Return the parsed labels and the new offset (skipping the null terminator)
return labels, offset + 1, nil
}
4.3 Format of the Answer section
The answer section contains ANCOUNT resource records that have the same format as the one described in Resource Record Structure.
However, the RDATA field in the Resource Record is variable length and format depends on the TYPE and CLASS of the record. For example, if the TYPE is A (IPv4 Address), the RDATA will be a 4-byte IP address. If the TYPE is NS (Name Server), the RDATA will be a domain name.
Because the Answer section follows immediately after the Question section, parsing it requires keeping track of the current offset in the message buffer.
4.3.1 Working with DNS Answers
Here is an example of how we can parse the Answer section.
func ParseAnswerSection(buf []byte, count uint16, offset int) ([]ResourceRecord, int, error) {
answers := make([]ResourceRecord, 0, count)
for i := 0; i < int(count); i++ {
if offset+10 > len(buf) {
return nil, 0, fmt.Errorf("buffer too short for RR header")
}
typ := binary.BigEndian.Uint16(buf[offset : offset+2])
class := binary.BigEndian.Uint16(buf[offset+2 : offset+4])
ttl := binary.BigEndian.Uint32(buf[offset+4 : offset+8])
rdLength := binary.BigEndian.Uint16(buf[offset+8 : offset+10])
offset += 10
// 3. Parse RData
if offset+int(rdLength) > len(buf) {
return nil, 0, fmt.Errorf("buffer too short for RData")
}
rdata := make([]byte, rdLength)
copy(rdata, buf[offset:offset+int(rdLength)])
offset += int(rdLength)
rr := ResourceRecord{
// Name: name,
Type: typ,
Class: class,
TTL: ttl,
RdLength: rdLength,
RData: rdata,
}
answers = append(answers, rr)
}
return answers, offset, nil
}
4.4 Authority Section
The Authority section contains Resource Records that point toward the authoritative name servers for the queried domain. This section is populated with information that helps the resolver continue the resolution process if the answering server cannot provide a direct answer to the query. Typically, you will find NS (Name Server) records here, which delegate the responsibility for the domain to other servers.
4.5 Additional Section
The Additional section contains Resource Records that relate to the query but are not strictly answers to the question. The purpose of this section is to provide “glue” or helpful information that the resolver might need next, effectively saving it from performing extra round-trip queries.
For example, if the Authority section provides the hostname of a Name Server (e.g., ns1.google.com), the Additional section might proactively include the A record (IPv4 address) for ns1.google.com. Without this, the resolver would have to pause and send a new query just to find the IP address of that name server.
4.6 Message Compression
In order to reduce the size of messages, the domain system utilizes a compression scheme that eliminates the repetition of domain names in a message.
In this scheme, an entire domain name or a list of labels at the end of a domain name is replaced with a pointer to a prior occurrence of the same name.
The pointer takes the form of a two-octet sequence:
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| 1 1| OFFSET |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
The first two bits are ones (11 in binary). This allows a computer processing the message to distinguish the pointer from a label, since the label must begin with two zero bits (because labels are restricted to 63 octets or less). (The 10 and 01 combinations are reserved for future use.)
The OFFSET field specifies an offset from the start of the message (i.e., the first byte of the ID field in the header). A zero offset specifies the first byte of the ID field.
For example, a pointer to offset 20 would be encoded as 11000000 00010100 in binary, or 0xC0 0x14 in hex.
4.6.1 Handling Compressed Names
Here’s a Go implementation that handles DNS compression by recursively following pointers. The key is detecting the 0xC0 (11000000) prefix, which indicates a pointer follows.
func (msg *Message) parseName() (string, error) {
// For compressed ones, it need not start from cursorIdx
length, name, err := msg.parseLabels(msg.cursorIdx)
if err != nil {
return "", err
}
msg.cursorIdx += length
return name, nil
}
func (msg *Message) parseLabels(position int) (int, string, error) {
if position > len(msg.rawPacket) {
return 0, "", fmt.Errorf("Position out of bounds. Packet Size: %d, Position: %d", len(msg.rawPacket), position)
}
var labels []string
length := 0
packet := msg.rawPacket
for {
if position+length >= len(packet) {
return 0, "", fmt.Errorf("Packet truncated")
}
nextByte := packet[position+length]
// Check for pointer (starts with 11xxxxxx)
if (nextByte & 0xC0) == 0xC0 {
if position+length+1 >= len(packet) {
return 0, "", fmt.Errorf("Packet truncated at pointer")
}
pointerByte := packet[position+length+1]
// The offset is the lower 14 bits
pointerPos := binary.BigEndian.Uint16([]byte{nextByte & 0x3F, pointerByte})
// Recursively parse the name at the pointer position
_, label, err := msg.parseLabels(int(pointerPos))
if err != nil {
return 0, "", err
}
labels = append(labels, label)
// Pointers are always 2 bytes long
length += 2
// A pointer terminates the sequence of labels at this position
break
}
// Regular label
length += 1
nextLabelLength := int(nextByte)
if nextLabelLength == 0 {
break // End of name
}
if position+length+nextLabelLength > len(packet) {
return 0, "", fmt.Errorf("Label length exceeds packet bounds")
}
label := string(packet[position+length : position+length+nextLabelLength])
labels = append(labels, label)
length += nextLabelLength
}
return length, strings.Join(labels, "."), nil
}
5. Resolver Types
While the DNS protocol format is universal, the servers that speak it play different roles. Understanding these roles is crucial when building your own server, as it dictates how you handle incoming queries.
5.1 Stub Resolver
This is likely running on your computer right now. A stub resolver is a simple client-side software (usually part of the Operating System) that doesn’t know how to traverse the DNS tree itself. When an application (like a web browser) needs an IP, the stub resolver simply forwards the query to a configured Recursive Resolver and waits for the final answer.
5.2 Recursive Resolver
These are the workhorses of the DNS world (examples include 8.8.8.8, 1.1.1.1, or your ISP’s DNS). When a recursor receives a query from a stub resolver, it performs the legwork of tracking down the answer.

If you were to query google.com, the recursive resolution process would look like this:
- Ask the Root: The recursor doesn’t know where
google.comis, but it knows where the Root Servers (.) are. It sends a query to one of them. - Referral to TLD: The Root Server replies, “I don’t know the IP for
google.com, but I know who manages.comdomains. Here are the IP addresses for the .com TLD (Top Level Domain) servers.” - Ask the TLD: The recursor sends the query to one of the
.comTLD servers. - Referral to Authoritative: The TLD server replies, “I don’t know the IP for
google.comspecifically, but I know the servers that manage that domain (e.g.,ns1.google.com). Here are their IP addresses.” - Ask the Authoritative: The recursor finally sends the query to
ns1.google.com. - Final Answer: Since this server is Authoritative for
google.com, it looks up the record in its database and returns the final answer (e.g., theA record 142.250.180.206) to the recursor. - Return to Client: The recursor caches this result (for the duration of the TTL) and returns it to your stub resolver.
5.3 Authoritative Nameserver
This is the ultimate source of truth for a specific domain zone. Unlike recursors, authoritative servers do not go asking around for answers; they only answer questions about domains they are configured to manage.
- If they have the record, they return it with the Authoritative Answer (AA) flag set to
1. - If they don’t, they return a reference to another server or an error (
NXDOMAIN).
5.4 Iterative vs. Recursive Queries
The distinction lies in who does the work:
- Recursive Query: “I don’t know the answer, please find it for me.” (Client -> Recursor)
- Iterative Query: “I don’t know the answer, but here is the address of someone who might know more.” (Recursor <-> Root/TLD/Authoritative Servers)
6. Conclusion
If you’ve read this far, let’s wrap up with one final exercise. The goal of this article wasn’t to teach you how to build a production-grade DNS server—we’ve barely scratched the surface—but rather to give you a basic but solid understanding of the DNS protocol itself. With that foundation, let’s analyze the output of DiG.
6.1 Analyzing DiG output
Let’s query Cloudflare DNS for google.com:
❯ dig @1.1.1.1 google.com
; <<>> DiG 9.20.17 <<>> @1.1.1.1 google.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 58708
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;google.com. IN A
;; ANSWER SECTION:
google.com. 278 IN A 172.217.19.110
;; Query time: 38 msec
;; SERVER: 1.1.1.1#53(1.1.1.1) (UDP)
;; WHEN: Wed Jan 21 12:28:33 CET 2026
;; MSG SIZE rcvd: 55
Let’s break down the dig output above and map it back to what we’ve learned.
The Header
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 58708
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
This corresponds directly to the Header Section we implemented.
id: 58708: The random 16-bit Packet Identifier.opcode: QUERY: Corresponds toOpcode 0(Standard Query).status: NOERROR: Corresponds toRCODE 0.flags:qr: Query Response (it’s a reply).rd: Recursion Desired (we asked for it).ra: Recursion Available (the server supports it).
- Counts:
QUERY: 1,ANSWER: 1match theQDCOUNT,ANCOUNTetc. fields.
The Question
;; QUESTION SECTION:
;google.com. IN A
This matches the Question Section.
- google.com.: The
QNAME. - IN: The
QCLASS(Internet). - A: The
QTYPE(IPv4 Address).
The Answer
;; ANSWER SECTION:
google.com. 278 IN A 172.217.19.110
This is the Answer Section containing the Resource Record.
- google.com.: The
NAME. - 278: The
TTL(Time To Live). - IN: The
CLASS. - A: The
TYPE. - 172.217.19.110: The
RDATA(the actual IP address).
Hopefully, you’ll now have a much clearer understanding of what’s happening under the hood whenever you have to deal with DNS.
6.2 Resources
Here are a couple of resources I’ve found to be useful.
6.2.1 Documentation
RFC 1034: DOMAIN NAMES - CONCEPTS AND FACILITIESRFC 1035: DOMAIN NAMES - IMPLEMENTATION AND SPECIFICATIONHerding the DNS CamelA warm welcome to DNSDNS CamelDNS server types
6.2.2 Source code
I’ve developed a recursive resolver implementation in this repository. Feel free to check it out if you’re interested.
6.2.3 Guided implementations
If you are interested, CodeCrafters provides a guided course (they provide tests and point out the most important things you should understand from the RFCs) where you will implement a stub resolver.
6.3 Appendices
6.3.1 512 bytes and TCP
You might have noticed the TC (Truncation) flag in the Header section. This flag is set to 1 if the message length exceeds the maximum size allowed for UDP packets, which is 512 bytes for standard DNS.
When a DNS client (stub resolver) receives a response with the TC flag set, it knows that the response was incomplete. To retrieve the full response, the client must retry the query using TCP instead of UDP.
This mechanism ensures that DNS remains lightweight and fast for the vast majority of queries while still being able to handle larger responses (like those containing DNSSEC signatures or many records) when necessary.
I’ve found this to be super interesting, since the layman’s understanding is usually, “DNS operates on port 53 via UDP”.