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:

TypeIDDescription
A1Address Record. Maps a hostname to a 32-bit IPv4 address.
AAAA28IPv6 Address Record. Maps a hostname to a 128-bit IPv6 address.
CNAME5Canonical Name. An alias that points one domain to another (e.g., www.example.com -> example.com).
MX15Mail Exchange. Specifies the mail server responsible for accepting email messages on behalf of a domain.
NS2Name Server. Delegates a DNS zone to use the given authoritative name servers.
TXT16Text Record. Originally for human-readable text, now mostly used for verification (SPF, DKIM, site ownership).
SOA6Start of authority. Denotes which DNS server has authority over a given DNS zone
Root, TLD and Authoritative name servers

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., 1 for A, 15 for MX).
  • CLASS: A 16-bit integer.
    • 1 = IN (Internet): This is the only class used in modern DNS.
    • 3 = CH (Chaos): Originally for the Chaosnet protocol from the 1970s. It is effectively obsolete but still sees niche use for querying server metadata (like version.bind).
    • 2 (CSNET) and 4 (Hesiod) are obsolete relics of the 1980s.
  • 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 RDATA field in bytes.
  • RDATA: The actual data describing the resource. The format of this field varies entirely depending on the TYPE and CLASS of 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                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
FieldSizeDescription
Packet Identifier (ID)16 bitsA random ID assigned to query packets. Response packets must reply with the same ID.
Query/Response Indicator (QR)1 bit1 for a reply packet, 0 for a question packet.
Operation Code (OPCODE)4 bitsSpecifies 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 bit1 if the responding server “owns” the domain queried, i.e., it’s authoritative.
Truncation (TC)1 bit1 if the message is larger than 512 bytes.
Recursion Desired (RD)1 bitSender sets this to 1 if the server should recursively resolve this query, 0 otherwise.
Recursion Available (RA)1 bitServer sets this to 1 to indicate that recursion is available.
Reserved (Z)3 bitsUsed by DNSSEC queries. At inception, it was reserved for future use.
Response Code (RCODE)4 bitsResponse 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 bitsNumber of questions in the Question section.
Answer Record Count (ANCOUNT)16 bitsNumber of records in the Answer section.
Authority Record Count (NSCOUNT)16 bitsNumber of records in the Authority section.
Additional Record Count (ARCOUNT)16 bitsNumber 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                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
FieldSizeDescription
Name (QNAME)VariableA domain name, represented as a sequence of “labels”.
Type (QTYPE)16 bitsThe type of record (1 for an A record, 5 for a CNAME record, etc.).
Class (QCLASS)16 bitsThe 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.

  • \x06google is the first label.
    • \x06 is the length byte (6).
    • google is the content (6 bytes).
  • \x03com is the second label.
    • \x03 is the length byte (3).
    • com is the content (3 bytes).
  • \x00 is 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.

Root, TLD and Authoritative name servers

If you were to query google.com, the recursive resolution process would look like this:

  1. Ask the Root: The recursor doesn’t know where google.com is, but it knows where the Root Servers (.) are. It sends a query to one of them.
  2. Referral to TLD: The Root Server replies, “I don’t know the IP for google.com, but I know who manages .com domains. Here are the IP addresses for the .com TLD (Top Level Domain) servers.”
  3. Ask the TLD: The recursor sends the query to one of the .com TLD servers.
  4. Referral to Authoritative: The TLD server replies, “I don’t know the IP for google.com specifically, but I know the servers that manage that domain (e.g., ns1.google.com). Here are their IP addresses.”
  5. Ask the Authoritative: The recursor finally sends the query to ns1.google.com.
  6. 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., the A record 142.250.180.206) to the recursor.
  7. 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 to Opcode 0 (Standard Query).
  • status: NOERROR: Corresponds to RCODE 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: 1 match the QDCOUNT, ANCOUNT etc. 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

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.