net/go.book
All Parts Marketing

DNS: Theory and Go Implementation

"Back in the IP addressing chapter, we compared IP addresses to house addresses. DNS is the phonebook that lets you say 'call the pizza place downtown' instead of memorizing its exact street number. Every time your code dials example.com:443, DNS is quietly doing the lookup before the TCP handshake even begins."


Why DNS Exists

Every net.Dial call you've written so far that used a hostname — net.Dial("tcp", "example.com:80") back in the TCP chapter — silently triggered a DNS lookup first. Computers route packets using numeric IP addresses; humans remember names. DNS (Domain Name System) is the distributed, hierarchical database that maps names to addresses (and back), so nobody has to memorize 93.184.216.34 when they mean example.com.


How DNS Resolution Works

Resolution is a hierarchy, not a single lookup:

  1. Root servers know which servers are authoritative for each top-level domain (.com, .org, .io, ...).
  2. TLD servers know which servers are authoritative for each domain under that TLD (example.com).
  3. Authoritative servers hold the actual records for that domain and answer with the final result.
  4. A recursive resolver (often run by your ISP, or a public one like 1.1.1.1) does this multi-step walk on your behalf and caches the result, so most lookups on the open internet never touch the root at all.

DNS runs primarily over UDP port 53 — the same connectionless, best-effort delivery model from the UDP chapter fits well here because most queries and responses are small and fit in a single packet. When a response would be too large for a single UDP datagram, DNS falls back to TCP port 53, the same way you saw large payloads eventually need TCP's framing and reliability.

Common Record Types

  • A / AAAA: Maps a name to an IPv4 or IPv6 address — the most common lookup.
  • CNAME: An alias pointing one name at another name.
  • MX: Mail server responsible for a domain.
  • TXT: Arbitrary text, often used for domain verification or SPF/DKIM email policy.
  • NS: The authoritative name servers for a domain.
  • PTR: Reverse lookup, mapping an IP address back to a name.

Every record also carries a TTL (time to live), telling resolvers how long they may cache the answer before asking again.

DNS trades a little staleness for a lot of speed — TTL is the dial that controls the trade.

Anatomy of a DNS Message

Both queries and responses share the same wire format, whether carried over UDP or TCP:

  • Header (12 bytes): a transaction ID (so a resolver can match a response to the query it sent, since UDP has no built-in request/reply correlation), flag bits (is this a query or response, is recursion desired, was the answer authoritative), and four counts — how many entries follow in the question, answer, authority, and additional sections.
  • Question section: the name being looked up, its record type (A, MX, ...), and its class (almost always IN, for internet).
  • Answer section: zero or more records matching the question, each with its own name, type, TTL, and data (an IP address for A, a hostname for CNAME, and so on).
  • Authority / additional sections: extra records the server includes proactively — for example, the IP address of a name server mentioned in the answer, saving the resolver a second round trip.

You never construct this format by hand when using Go's net package — the Lookup* functions and net.Resolver handle encoding the query and parsing the response for you. Knowing the shape matters anyway: it explains why DNS responses fit in a single UDP datagram most of the time (a handful of short records plus a fixed 12-byte header), and why an attacker who can predict or guess the 16-bit transaction ID can attempt to inject a forged response — one of the historical motivations for DNSSEC, discussed later in this chapter.


Go Implementation: Standard Lookups

Go's net package exposes DNS resolution through a family of Lookup* functions, which is what net.Dial uses internally whenever you pass it a hostname instead of an IP.

package main

import (
	"fmt"
	"net"
)

func main() {
	// A/AAAA records: resolve a hostname to its IP addresses.
	ips, err := net.LookupHost("example.com")
	if err != nil {
		fmt.Println("lookup error:", err)
		return
	}
	fmt.Println("A/AAAA records:", ips)

	// MX records: which servers handle mail for this domain.
	mxRecords, err := net.LookupMX("example.com")
	if err == nil {
		for _, mx := range mxRecords {
			fmt.Printf("MX: %s (priority %d)\n", mx.Host, mx.Pref)
		}
	}

	// TXT records: arbitrary text data attached to the domain.
	txtRecords, err := net.LookupTXT("example.com")
	if err == nil {
		fmt.Println("TXT records:", txtRecords)
	}

	// PTR (reverse) lookup: IP address back to hostname.
	names, err := net.LookupAddr("93.184.216.34")
	if err == nil {
		fmt.Println("reverse lookup:", names)
	}
}

Exercise: DNS Lookups

net.LookupHost has no built-in timeout
net.LookupHost, net.LookupMX, and the other package-level Lookup* functions take no context.Context and no deadline — they block until the underlying resolver answers, fails, or the operating system's own resolver timeout (often 5 seconds per server, sometimes multiplied across several configured servers) finally gives up. In a program handling many requests concurrently, a single unresponsive DNS server can quietly stall every goroutine that happens to trigger a lookup at the same time. Prefer net.Resolver.LookupHost(ctx, host) (shown next) with a bounded context.WithTimeout, so a slow resolver fails fast instead of hanging the caller indefinitely.


Custom Resolvers

By default, Go's resolver behavior depends on the platform: it may use the operating system's native resolver (via cgo) or a pure-Go implementation. You can take full control with net.Resolver, including pointing it at a specific DNS server — useful for testing against a private DNS server or forcing the pure-Go path:

resolver := &net.Resolver{
	PreferGo: true,
	Dial: func(
		ctx context.Context, network, address string,
	) (net.Conn, error) {
		d := net.Dialer{Timeout: 5 * time.Second}
		// Ignore the system-configured server and query Cloudflare's
		// public resolver directly.
		return d.DialContext(ctx, network, "1.1.1.1:53")
	},
}

// Give the whole lookup a hard deadline, addressing the timeout
// pitfall from the previous warning — if 1.1.1.1 doesn't answer within
// two seconds, ctx.Err() unblocks the caller instead of hanging.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

ips, err := resolver.LookupHost(ctx, "example.com")

This is the same context.Context-driven, pluggable-dial pattern you saw with net.Dialer.DialContext in earlier chapters — DNS resolution is just another network operation that benefits from cancellation and timeouts.


Building a Tiny DNS Cache

Because DNS lookups add latency to every new connection, applications that make many outbound requests often cache results themselves, respecting the record's TTL:

type cacheEntry struct {
	ips     []string
	err     error
	expires time.Time
}

type dnsCache struct {
	mu      sync.Mutex
	entries map[string]cacheEntry
}

func (c *dnsCache) lookup(host string) ([]string, error) {
	c.mu.Lock()
	if entry, ok := c.entries[host]; ok && time.Now().Before(entry.expires) {
		defer c.mu.Unlock()
		if entry.err != nil {
			return nil, entry.err
		}
		return entry.ips, nil
	}
	c.mu.Unlock()

	ips, err := net.LookupHost(host)

	// Negative caching: a lookup failure (NXDOMAIN, a resolver timeout)
	// is cached too, just for a shorter window. Without this, a hostname
	// that doesn't exist gets re-queried on every single call site that
	// asks for it, which is exactly the kind of repeated, avoidable
	// traffic caching was meant to eliminate in the first place.
	ttl := 60 * time.Second
	if err != nil {
		ttl = 5 * time.Second
	}

	c.mu.Lock()
	c.entries[host] = cacheEntry{
		ips:     ips,
		err:     err,
		expires: time.Now().Add(ttl),
	}
	c.mu.Unlock()
	return ips, err
}

Exercise: DNS Cache

This mirrors the concurrency-safety patterns from the concurrency chapter: a sync.Mutex guards the shared map because multiple goroutines may resolve hostnames concurrently. Note that cacheEntry now needs an err error field alongside ips and expires to support the negative-caching path above.

Real TTLs, not a guess
The 60-second expiry above is a placeholder for the exercise. Go's standard net package does not expose the actual TTL returned by the DNS server — if you need TTL-accurate caching, you generally reach for a dedicated DNS library that parses raw records, rather than the net.Lookup* convenience functions.

A shared mutex serializes every lookup, even cache hits
Every call to lookup takes c.mu at least once, even when the entry is already cached and valid. For a read-heavy cache with many concurrent goroutines, that single mutex can become a bottleneck under high concurrency. A sync.RWMutex (read lock for the cache-hit path, write lock only when inserting) or sync.Map — which is optimized for exactly this read-mostly, write-rarely access pattern — are the usual next steps once a plain sync.Mutex shows up in a profile.


Try It Yourself: A Resolver with Fallback

Extend the custom net.Resolver from this chapter so a slow or unreachable primary DNS server doesn't stall every lookup:

  1. Wrap the Dial function so it first tries the primary server (say, 1.1.1.1:53) with a short context.WithTimeout (around 1-2 seconds).
  2. If that attempt's context deadline expires or the dial fails, retry once against a secondary server (8.8.8.8:53) with a fresh, similarly short timeout — the same layered-timeout idea used for the retrying TCP file transfer client earlier in this book.
  3. Log which server actually answered, so you can tell in practice how often the fallback path is exercised versus the primary succeeding.

This is a miniature version of what production recursive resolvers do constantly: DNS servers are configured in a list precisely because any single one of them can become slow or unreachable, and a resolver that gives up after one attempt trades a small amount of extra latency for a much larger amount of unnecessary lookup failures.


Frequently Asked Questions

Does every net.Dial call really trigger a fresh DNS lookup? Yes, unless you cache the result yourself — Go's net package has no built-in resolver cache. Call net.Dial("tcp", "example.com:80") a hundred times in a tight loop and you trigger a hundred lookups, which is exactly the repeated, avoidable traffic the tiny DNS cache built later in this chapter is meant to eliminate.

Why does net.LookupHost hang instead of just failing fast on a bad DNS server? Because the package-level Lookup* functions predate context.Context and were never given a timeout parameter — they simply wait on whatever the operating system's resolver decides, which can be five seconds or more per configured server. That is precisely why the custom-resolver section reaches for net.Resolver.LookupHost(ctx, host) instead: wrapping the call in context.WithTimeout turns an indefinite hang into a bounded, predictable failure.

If I set PreferGo: true, do I lose /etc/hosts and /etc/resolv.conf support? You do, and that is the whole trade-off. The cgo path calls the OS's native getaddrinfo, which transparently honors /etc/hosts, NSS modules, and corporate LDAP-backed resolution; the pure-Go path speaks DNS directly over the wire and skips all of that machinery. The payoff is a fully static, portable binary and the ability to redirect queries to a specific server via the Dial field, which the cgo path gives you no hook to do.

Why does Go's standard library not just validate DNSSEC for me? Because net is deliberately a thin convenience layer over whatever answer comes back, not a security-hardened DNS stack — the same reasoning that keeps it from exposing real record TTLs. If your application needs cryptographic proof that a response actually came from the domain's authoritative servers, rather than merely "some server on the path answered," that is a job for a dedicated library like miekg/dns, not net.LookupHost.

My tiny DNS cache is a bottleneck under load — what's the first thing to change? Look at the mutex before anything else. A plain sync.Mutex in the lookup method serializes every call, including cache hits that are read-only, so the fix is almost always swapping it for a sync.RWMutex (read lock on the hit path, write lock only when inserting) or a sync.Map, which is built exactly for a read-mostly, write-rarely workload like this one.


Key Takeaways

  • DNS is a hierarchical, cached lookup system that turns names into addresses (and back) — mostly over UDP, falling back to TCP for larger responses.
  • net.LookupHost, net.LookupMX, net.LookupTXT, and net.LookupAddr cover the common record types without any extra dependencies.
  • net.Resolver with a custom Dial function lets you target a specific DNS server or force Go's pure-Go resolver.
  • Caching lookups reduces latency for applications making many outbound connections, but respect TTLs and guard shared caches with a mutex.