DNS Protocol Fundamentals
What Is the DNS Protocol?
The Domain Name System (DNS) is the internet's phonebook—a hierarchical, distributed database that maps human-friendly domain names (like example.com) to machine-routable IP addresses (like 93.184.216.34). At its core, DNS is a binary protocol operating over UDP (and sometimes TCP) on port 53. It uses a strict request–response model defined by a compact wire format, where every packet has a header, question section, answer section, authority section, and additional section. Understanding this wire format is essential for building anything from a custom resolver to a captive portal detection system.
Why DNS Matters for Developers
DNS sits at the foundation of every networked application. Beyond simple name resolution, modern development relies on DNS for:
- Service discovery — Kubernetes, Consul, and cloud load balancers use DNS records to locate microservices.
- Traffic routing — Geo-DNS and weighted records steer users to the nearest or healthiest endpoints.
- Security — DNS filtering blocks malicious domains; DNSSEC prevents spoofing.
- Observability — Monitoring DNS query patterns reveals misconfigurations and exfiltration attempts.
Implementing DNS from scratch gives you complete control over resolution logic, bypasses OS stub resolver limitations, and deepens your understanding of internet infrastructure.
How DNS Resolution Works
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →The Recursive Resolution Walk
When a client asks for www.example.com, a typical recursive resolver follows these steps:
- Send query to a root server (e.g.,
198.41.0.4); receive referral to.comnameservers. - Send query to a
.comnameserver; receive referral toexample.comauthoritative servers. - Send query to an
example.comauthoritative server; receive the final A record.
The DNS wire format carries the same structure regardless of the step—only the server and the question change. A DNS packet consists of a fixed 12‑byte header, followed by variable‑length sections.
DNS Message Wire Format
Every DNS message (query and response) has the layout:
+---------------------+
| Header | 12 bytes: ID, flags, counts
+---------------------+
| Question | Variable: domain name, type, class
+---------------------+
| Answer | Variable: resource records
+---------------------+
| Authority | Variable: NS records, etc.
+---------------------+
| Additional | Variable: glue records, OPT
+---------------------+
The header contains:
- ID (16 bits): matches query with response.
- QR (1 bit): 0 for query, 1 for response.
- OPCODE (4 bits): standard query is 0.
- AA, TC, RD, RA flags: authoritative answer, truncation, recursion desired, recursion available.
- RCODE (4 bits): response code (0 = no error, 3 = NXDOMAIN).
- QDCOUNT, ANCOUNT, NSCOUNT, ARCOUNT: number of entries in each section.
Implementing a DNS Client from Scratch
Building a Query Packet in Python
We'll construct a raw DNS query for an A record of example.com. The domain name must be encoded with length‑prefixed labels (no dots), terminated by a zero byte. We'll use the struct module for binary packing.
import struct
import socket
def build_dns_query(domain: str, query_type: int = 1) -> bytes:
# Header: ID=0x1234, flags=0x0100 (standard query, recursion desired)
header = struct.pack('!HHHHHH',
0x1234, # ID
0x0100, # Flags: QR=0, OPCODE=0, AA=0, TC=0, RD=1, RA=0, Z=0, RCODE=0
1, # QDCOUNT (one question)
0, # ANCOUNT
0, # NSCOUNT
0 # ARCOUNT
)
# Encode domain name: split into labels, prefix each with length byte
labels = domain.split('.')
question_name = b''
for label in labels:
question_name += bytes([len(label)]) + label.encode('ascii')
question_name += b'\x00' # terminating zero length
# Question: QTYPE (A = 1), QCLASS (IN = 1)
question = question_name + struct.pack('!HH', query_type, 1)
return header + question
# Example usage
query = build_dns_query('example.com')
print(query.hex())
This produces a valid 29‑byte DNS query. The ID 0x1234 is arbitrary; real implementations generate a random ID to avoid spoofing. The flags field 0x0100 sets only the RD (Recursion Desired) bit.
Sending the Query and Receiving a Response
DNS uses UDP port 53. We'll send the query to Google's public resolver 8.8.8.8 and receive up to 512 bytes (the classic DNS maximum over UDP). Larger responses require TCP, but A record queries typically fit.
def send_query(query: bytes, server: str = '8.8.8.8', port: int = 53) -> bytes:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(3)
try:
sock.sendto(query, (server, port))
response, _ = sock.recvfrom(512) # standard UDP limit
return response
except socket.timeout:
raise TimeoutError('DNS query timed out')
finally:
sock.close()
response = send_query(build_dns_query('example.com'))
print(f"Received {len(response)} bytes")
Parsing the DNS Response
A full parser handles compressed names (pointers) and multiple record types. For clarity, we'll extract the header fields and decode the first A record from the answer section.
def parse_dns_response(response: bytes):
# Unpack header
header = struct.unpack_from('!HHHHHH', response, 0)
id_, flags, qdcount, ancount, nscount, arcount = header
# Decode flags
qr = (flags >> 15) & 1
opcode = (flags >> 11) & 0xF
rcode = flags & 0xF
print(f"ID: {id_:#x}, QR: {qr}, Opcode: {opcode}, RCODE: {rcode}")
print(f"Questions: {qdcount}, Answers: {ancount}")
offset = 12 # skip header
# Skip question section (assume one question with standard encoding)
while response[offset] != 0:
label_len = response[offset]
offset += 1 + label_len
offset += 1 # zero byte
offset += 4 # QTYPE + QCLASS
# Parse answer records
for i in range(ancount):
# Decode name (may use compression pointers)
name, offset = decode_name(response, offset)
rtype, rclass, ttl, rdlength = struct.unpack_from('!HHIH', response, offset)
offset += 10
rdata = response[offset:offset+rdlength]
offset += rdlength
if rtype == 1: # A record
ip = '.'.join(str(b) for b in rdata)
print(f"A record: {name} -> {ip} (TTL: {ttl}s)")
elif rtype == 28: # AAAA record
# format IPv6 address
ip = ':'.join(f'{rdata[i]:02x}{rdata[i+1]:02x}' for i in range(0,16,2))
print(f"AAAA record: {name} -> {ip} (TTL: {ttl}s)")
# else skip other types for brevity
def decode_name(data: bytes, offset: int):
"""Decode a domain name, handling compression pointers (RFC 1035)."""
labels = []
jumped = False
original_offset = offset
while True:
length = data[offset]
if length == 0:
offset += 1
break
# Check for compression pointer (top two bits set)
if (length & 0xC0) == 0xC0:
pointer = struct.unpack_from('!H', data, offset)[0] & 0x3FFF
if not jumped:
original_offset = offset + 2 # save position after pointer
offset = pointer
jumped = True
continue
offset += 1
labels.append(data[offset:offset+length].decode('ascii'))
offset += length
if jumped:
offset = original_offset
return '.'.join(labels), offset
# Example usage
response = send_query(build_dns_query('example.com'))
parse_dns_response(response)
This parser handles the common compression scheme where a domain name suffix is replaced by a 2‑byte pointer to an earlier occurrence in the packet. Real‑world responses often chain multiple pointers; our loop handles that correctly.
Best Practices for DNS Implementations
Error Handling and Retries
DNS over UDP is inherently unreliable. Always implement exponential backoff with jitter:
import random
import time
def resolve_with_retry(domain, max_retries=3):
base_delay = 0.5
for attempt in range(max_retries):
try:
query = build_dns_query(domain)
response = send_query(query)
# Validate response ID and RCODE
header = struct.unpack_from('!HH', response, 0)
if header[0] != 0x1234:
raise ValueError('Response ID mismatch')
rcode = header[1] & 0xF
if rcode == 3:
return None # NXDOMAIN
if rcode != 0:
raise RuntimeError(f'Server error RCODE={rcode}')
return response
except (TimeoutError, OSError) as e:
delay = base_delay * (2 ** attempt) + random.uniform(0, 0.1)
time.sleep(delay)
raise RuntimeError('All DNS attempts failed')
Also handle truncated responses (TC bit set). If the TC flag is 1, you must retry over TCP on port 53 using the same query. TCP DNS prepends a 2‑byte length field.
Caching and TTL Management
Respect the TTL (Time‑to‑Live) in resource records. Maintain an in‑memory cache keyed by (domain, type). Before sending a query, check the cache; if the record hasn't expired, return it immediately.
import time
from collections import namedtuple
CacheEntry = namedtuple('CacheEntry', ['records', 'expires_at'])
dns_cache = {}
def cached_resolve(domain: str, qtype: int = 1):
key = (domain, qtype)
now = time.time()
if key in dns_cache and dns_cache[key].expires_at > now:
return dns_cache[key].records
response = resolve_with_retry(domain)
if response is None:
return None
# Parse and extract records with TTLs (simplified)
records = []
# ... parsing code, collecting (record, min_ttl) ...
# Store with earliest expiration
min_ttl = min(ttl for _, ttl in records) if records else 300
dns_cache[key] = CacheEntry(records, now + min_ttl)
return [r for r, _ in records]
For production, use a proper LRU cache and consider negative caching (NXDOMAIN, SERVFAIL) with shorter TTLs as recommended by RFC 2308.
Security Considerations: DNSSEC
The DNS protocol itself is unauthenticated; DNSSEC adds cryptographic signatures to verify responses. When implementing a resolver, you can optionally validate DNSSEC by:
- Setting the DO (DNSSEC OK) bit in the EDNS0 OPT pseudo‑record.
- Verifying RRSIG records using the chain of trust from the root DS record.
Building full DNSSEC validation is complex. For most custom tools, rely on a validating upstream resolver (like 8.8.8.8 or 1.1.1.1) and check the AD (Authenticated Data) flag in responses.
Rate Limiting and Concurrency
A raw UDP client can easily flood servers. Implement a token‑bucket or sliding‑window rate limiter per upstream resolver. For asynchronous usage, use asyncio with non‑blocking UDP or a pool of sockets. Never issue more than a few hundred queries per second to a single resolver to avoid being blocked.
Conclusion
Implementing the DNS protocol from scratch demystifies one of the internet's most fundamental systems. By mastering the binary wire format, recursive resolution logic, and best practices around caching, error handling, and security, you gain the ability to build custom resolvers, proxy‑level DNS filters, or advanced service‑discovery mechanisms. The code examples above provide a solid foundation—extend them with EDNS0, TCP fallback, or DNSSEC validation to suit your specific needs. Whether you're debugging network issues or creating a new distributed system, a deep understanding of DNS will serve you well throughout your career as a developer.