Go's Pure-Go DNS Resolver and the Hosts File Fast Path
net.ResolveIPAddr walks a five-step lookup order derived from nsswitch.conf: check /etc/hosts, then query the pure-Go DNS client against the nameservers in /etc/resolv.conf, with cgo as the fallback.
TL;DR:
net.LookupIPdoesn’t just “do DNS”. It picks a strategy from/etc/nsswitch.conf, tries/etc/hostsfirst (cached for five seconds), then falls back to a pure-Go DNS client that reads/etc/resolv.conf, walks the search-domain list, and retries across up to three nameservers over UDP and then TCP. If any of that is unrecognised, it hands the whole thing to libc via cgo.
Most people meet the Go resolver for the first time when a name resolves
differently than it does with dig. That’s usually because Go is using a
different source of truth than the tool you were comparing against — and
sometimes because it isn’t using DNS at all.
This is a walk through how net actually resolves a name, based on reading
the Go source (1.28 development tree, commit c0be4674865b).
The shape of the whole thing
Resolution in package net is a pipeline with a switch at the top:
net.LookupIP("example.com")
|
v
Resolver.lookupIP src/net/lookup_unix.go:64
|
v
systemConf().hostLookupOrder(r, host) src/net/conf.go:232
|
+--> hostLookupCgo ----------------------------> cgoLookupIP (libc)
|
+--> hostLookupFilesDNS / hostLookupFiles
| |
| v
| goLookupIPCNAMEOrder src/net/dnsclient_unix.go:657
| |
| +--> goLookupIPFiles -> lookupStaticHost src/net/hosts.go:144
| |
| +--> conf.nameList(name) -> tryOneName -> exchange
| src/net/dnsclient_unix.go:543
| src/net/dnsclient_unix.go:297
| src/net/dnsclient_unix.go:169
|
+--> hostLookupDNSFiles (DNS first, files as a fallback)The whole thing hangs off one enum:
// src/net/dnsclient_unix.go:581
// hostLookupOrder specifies the order of LookupHost lookup strategies.
// It is basically a simplified representation of nsswitch.conf.
// "files" means /etc/hosts.
type hostLookupOrder int
const (
// hostLookupCgo means defer to cgo.
hostLookupCgo hostLookupOrder = iota
hostLookupFilesDNS // files first
hostLookupDNSFiles // dns first
hostLookupFiles // only files
hostLookupDNS // only DNS
)Five options. Note that “cgo” isn’t a step in the chain — it’s an alternative to having a chain at all.
Where the order comes from
conf.go is where the decision gets made, and it’s a long function because
there are a lot of operating systems to accommodate. The shape is:
// src/net/conf.go:241
func (c *conf) lookupOrder(r *Resolver, hostname string) (ret hostLookupOrder, dnsConf *dnsConfig) {
// fallbackOrder is the order we return if we can't figure it out.
var fallbackOrder hostLookupOrder
var canUseCgo bool
if c.mustUseGoResolver(r) {
// Go resolver was explicitly requested
// or cgo resolver is not available.
// Figure out the order below.
fallbackOrder = hostLookupFilesDNS
canUseCgo = false
} else if c.netCgo {
// Cgo resolver was explicitly requested.
return hostLookupCgo, nil
} else if c.preferCgo {
// Given a choice, we prefer the cgo resolver.
return hostLookupCgo, nil
} else {
// Neither resolver was explicitly requested
// and we have no preference.
if bytealg.IndexByteString(hostname, '\\') != -1 || bytealg.IndexByteString(hostname, '%') != -1 {
// Don't deal with special form hostnames
// with backslashes or '%'.
return hostLookupCgo, nil
}
// If something is unrecognized, use cgo.
fallbackOrder = hostLookupCgo
canUseCgo = true
}Three ways to get here, in order of precedence:
Resolver.PreferGois set, or cgo isn’t available — you get the pure-Go resolver with a fallback order offiles,dns.GODEBUG=netdns=cgo— you get libc, full stop.- Neither — the runtime reads
/etc/nsswitch.confand figures it out.
In the third case, the runtime reads the hosts: line of nsswitch.conf and
walks the listed sources in order:
// src/net/conf.go:348
// Canonicalize the hostname by removing any trailing dot.
hostname = stringslite.TrimSuffix(hostname, ".")
nss := getSystemNSS()
srcs := nss.sources["hosts"]
// If /etc/nsswitch.conf doesn't exist or doesn't specify any
// sources for "hosts", assume Go's DNS will work fine.
if errors.Is(nss.err, fs.ErrNotExist) || (nss.err == nil && len(srcs) == 0) {
if canUseCgo && c.goos == "solaris" {
// illumos defaults to
// "nis [NOTFOUND=return] files",
// which the go resolver doesn't support.
return hostLookupCgo, dnsConf
}
return hostLookupFilesDNS, dnsConf
}So on a box with no nsswitch.conf — which is most containers — Go
defaults to files,dns: check /etc/hosts first, then DNS.
If nsswitch.conf does exist and lists a source Go doesn’t understand, Go
bails out to cgo. This is the important bit for anyone debugging “why does Go
resolve differently from dig”:
// src/net/conf.go:395
if canUseCgo {
switch {
case hostname != "" && src.source == "myhostname":
// Let the cgo resolver handle myhostname
// if we are looking up the local hostname.
if isLocalhost(hostname) || isGateway(hostname) || isOutbound(hostname) {
return hostLookupCgo, dnsConf
}
hn, err := getHostname()
if err != nil || stringsEqualFold(hostname, hn) {
return hostLookupCgo, dnsConf
}
continue
case hostname != "" && stringslite.HasPrefix(src.source, "mdns"):
if stringsHasSuffixFold(hostname, ".local") {
// Per RFC 6762, the ".local" TLD is special. And
// because Go's native resolver doesn't do mDNS or
// similar local resolution mechanisms, assume that
// libc might (via Avahi, etc) and use cgo.
return hostLookupCgo, dnsConf
}
// We don't parse mdns.allow files. They're rare. If one
// exists, it might list other TLDs (besides .local) or even
// '*', so just let libc deal with it.
var haveMDNSAllow bool
switch c.mdnsTest {
case mdnsFromSystem:
_, err := os.Stat("/etc/mdns.allow")
if err != nil && !errors.Is(err, fs.ErrNotExist) {
// Let libc figure out what is going on.
return hostLookupCgo, dnsConf
}
haveMDNSAllow = err == nil
case mdnsAssumeExists:
haveMDNSAllow = true
case mdnsAssumeDoesNotExist:
haveMDNSAllow = false
}
if haveMDNSAllow {
return hostLookupCgo, dnsConf
}
continue
default:
// Some source we don't know how to deal with.
return hostLookupCgo, dnsConf
}
}Go’s pure-Go resolver deliberately does not implement mDNS, NSS
myhostname, NIS, or LDAP. The moment it sees one of those in
nsswitch.conf, it hands the whole lookup to libc rather than guess.
And when everything is recognised, the order is decided by which source came first:
// src/net/conf.go:465
// Cases where Go can handle it without cgo and C thread overhead,
// or where the Go resolver has been forced.
switch {
case filesSource && dnsSource:
if first == "files" {
return hostLookupFilesDNS, dnsConf
} else {
return hostLookupDNSFiles, dnsConf
}
case filesSource:
return hostLookupFiles, dnsConf
case dnsSource:
return hostLookupDNS, dnsConf
}That’s it. hosts: files dns gives you files,dns. hosts: dns files gives
you dns,files. Two entries, two orders, and one interesting difference
between them that we’ll come back to.
The hosts file
Before any DNS happens, Go checks /etc/hosts. The cache is a package-level
struct with two maps and an expiry:
// src/net/hosts.go:32
// hosts contains known host entries.
var hosts struct {
sync.Mutex
// Key for the list of literal IP addresses must be a host
// name. It would be part of DNS labels, a FQDN or an absolute
// FQDN.
// For now the key is converted to lower case for convenience.
byName map[string]byName
// Key for the list of host names must be a literal IP address
// including IPv6 address with zone identifier.
// We don't support old-classful IP address notation.
byAddr map[string][]string
expire time.Time
path string
fi fs.FileInfo
}and the cache lifetime is five seconds:
// src/net/hosts.go:17
const cacheMaxAge = 5 * time.SecondThe invalidation logic is the interesting part, because a naive “check the mtime” is wrong in a specific way:
// src/net/hosts.go:52
// unchangedHostsFile reports whether fi refers to the same file as the one
// cached in hosts, still with the same modification time and size. Replacing
// the hosts file with a rename leaves the modification time and the size of
// the replacement untouched, so the file identity has to be checked too.
func unchangedHostsFile(fi fs.FileInfo) bool {
return hosts.fi != nil && os.SameFile(hosts.fi, fi) &&
hosts.fi.ModTime().Equal(fi.ModTime()) && hosts.fi.Size() == fi.Size()
}os.SameFile is the key. If your init container or config-management tool
replaces /etc/hosts with mv /etc/hosts.new /etc/hosts, the new file
inherits… well, it depends, but the mtime and size can easily match the
old one. The inode number doesn’t. So Go compares the file identity as well.
The read path:
// src/net/hosts.go:61
func readHosts() {
now := time.Now()
hp := hostsFilePath
if now.Before(hosts.expire) && hosts.path == hp && len(hosts.byName) > 0 {
return
}
if sfi, err := stat(hp); err == nil && hosts.path == hp && unchangedHostsFile(sfi) {
hosts.expire = now.Add(cacheMaxAge)
return
}Three cases:
- Cache still fresh — return immediately, don’t even
stat. - File looks unchanged — extend the expiry by five seconds without re-reading.
- Otherwise — re-read and re-parse.
So a program that resolves a name in a tight loop will stat /etc/hosts
at most once every five seconds, and re-read it only if it actually
changed.
The lookup itself is a map hit with a lowercasing pass:
// src/net/hosts.go:143
// lookupStaticHost looks up the addresses and the canonical name for the given host from /etc/hosts.
func lookupStaticHost(host string) ([]string, string) {
hosts.Lock()
defer hosts.Unlock()
readHosts()
if len(hosts.byName) != 0 {
if hasUpperCase(host) {
lowerHost := []byte(host)
lowerASCIIBytes(lowerHost)
host = string(lowerHost)
}
if byName, ok := hosts.byName[absDomainName(host)]; ok {
ipsCp := make([]string, len(byName.addrs))
copy(ipsCp, byName.addrs)
return ipsCp, byName.canonicalName
}
}
return nil, ""
}Note the copy. The caller gets a fresh slice every time, because the
cached slice is shared and the caller is free to sort or mutate it.
The two orders are not the same
Once we have the order, goLookupIPCNAMEOrder handles it:
// src/net/dnsclient_unix.go:657
func (r *Resolver) goLookupIPCNAMEOrder(ctx context.Context, network, name string, order hostLookupOrder, conf *dnsConfig) (addrs []IPAddr, cname dnsmessage.Name, err error) {
if order == hostLookupFilesDNS || order == hostLookupFiles {
var canonical string
addrs, canonical = goLookupIPFiles(name)
if len(addrs) > 0 {
var err error
cname, err = dnsmessage.NewName(canonical)
if err != nil {
return nil, dnsmessage.Name{}, err
}
return addrs, cname, nil
}
if order == hostLookupFiles && !isLocalhostName(name) {
return nil, dnsmessage.Name{}, newDNSError(errNoSuchHost, name, "")
}
}For files,dns and files only: look in the hosts file first, and if
there’s a hit, return it. No DNS query at all.
For dns,files, the hosts file is only consulted at the very end, if DNS
found nothing:
// src/net/dnsclient_unix.go:868
if len(addrs) == 0 && !(network == "CNAME" && cname.Length > 0) {
if order == hostLookupDNSFiles {
var canonical string
addrs, canonical = goLookupIPFiles(name)
if len(addrs) > 0 {
var err error
cname, err = dnsmessage.NewName(canonical)
if err != nil {
return nil, dnsmessage.Name{}, err
}
return addrs, cname, nil
}
}So hosts: dns files really does mean “ask the network first, and only
then look at the local file”. That’s a real behavioural difference and a
real source of “it works in the shell but not in my Go program” bugs — in
one direction or the other.
/etc/resolv.conf
When DNS is needed, the pure-Go client reads /etc/resolv.conf:
// src/net/dnsconfig_unix.go:18
// See resolv.conf(5) on a Linux machine.
func dnsReadConfig(filename string) *dnsConfig {
conf := &dnsConfig{
ndots: 1,
timeout: 5 * time.Second,
attempts: 2,
}
file, err := open(filename)
if err != nil {
conf.servers = defaultNS
conf.search = dnsDefaultSearch()
conf.err = err
return conf
}The defaults are worth memorising: one dot in ndots, a five second
timeout, and two attempts per server.
The parser is a switch over the keywords:
// src/net/dnsconfig_unix.go:50
switch f[0] {
case "nameserver": // add one name server
if len(f) > 1 && len(conf.servers) < 3 { // small, but the standard limit
// One more check: make sure server name is
// just an IP address. Otherwise we need DNS
// to look it up.
if _, err := netip.ParseAddr(f[1]); err == nil {
conf.servers = append(conf.servers, JoinHostPort(f[1], "53"))
}
}At most three nameservers. That matches the traditional resolv.conf
limit and the BIND RESOLV_MAXNS value of 3. Modern systems that put
more than three in there get silently truncated.
The options keyword is where the interesting knobs live:
// src/net/dnsconfig_unix.go:76
case "options": // magic options
for _, s := range f[1:] {
switch {
case stringslite.HasPrefix(s, "ndots:"):
n, _, _ := dtoi(s[6:])
if n < 0 {
n = 0
} else if n > 15 {
n = 15
}
conf.ndots = n
case stringslite.HasPrefix(s, "timeout:"):
n, _, _ := dtoi(s[8:])
if n < 1 {
n = 1
}
conf.timeout = time.Duration(n) * time.Second
case stringslite.HasPrefix(s, "attempts:"):
n, _, _ := dtoi(s[9:])
if n < 1 {
n = 1
}
conf.attempts = n
case s == "rotate":
conf.rotate = true
case s == "single-request" || s == "single-request-reopen":
// Linux option:
// http://man7.org/linux/man-pages/man5/resolv.conf.5.html
// "By default, glibc performs IPv4 and IPv6 lookups in parallel [...]
// This option disables the behavior and makes glibc
// perform the IPv6 and IPv4 requests sequentially."
conf.singleRequest = true
case s == "use-vc" || s == "usevc" || s == "tcp":
// Linux (use-vc), FreeBSD (usevc) and OpenBSD (tcp) option:
// http://man7.org/linux/man-pages/man5/resolv.conf.5.html
// "Sets RES_USEVC in _res.options.
// This option forces the use of TCP for DNS resolutions."
// https://www.freebsd.org/cgi/man.cgi?query=resolv.conf&sektion=5&manpath=freebsd-release-ports
// https://man.openbsd.org/resolv.conf.5
conf.useTCP = true
case s == "trust-ad":
conf.trustAD = true
case s == "edns0":
// We use EDNS by default.
// Ignore this option.
case s == "no-reload":
conf.noReload = trueTwo things to notice. ndots is clamped to [0, 15]. And an
unrecognised option sets unknownOpt, which is what pushes the whole
lookup over to cgo later:
// src/net/conf.go:292
if canUseCgo && dnsConf.unknownOpt {
// We didn't recognize something in resolv.conf,
// so use cgo if we can.
return hostLookupCgo, dnsConf
}So an options line with something exotic in it — options ndots:1 edns0 trust-ad foo:bar — silently sends all of your DNS to libc.
The parsed structure is small and readable:
// src/net/dnsconfig.go:36
type dnsConfig struct {
servers []string // server addresses (in host:port form) to use
search []string // rooted suffixes to append to local name
ndots int // number of dots in name to trigger absolute lookup
timeout time.Duration // wait before giving up on a query, including retries
attempts int // lost packets before giving up on server
rotate bool // round robin among servers
unknownOpt bool // anything unknown was encountered
lookup []string // OpenBSD top-level database "lookup" order
err error // any error that occurs during open of resolv.conf
mtime time.Time // time of resolv.conf modification
soffset uint32 // used by serverOffset
singleRequest bool // use sequential A and AAAA queries instead of parallel queries
useTCP bool // force usage of TCP for DNS resolutions
trustAD bool // add AD flag to queries
noReload bool // do not check for config file updates
}Reloading
The config is cached and re-checked lazily. tryUpdate is called on every
lookup, but it does almost nothing most of the time:
// src/net/dnsclient_unix.go:422
// Only recheck the resolv.conf when:
// - expired (last re-check was more that 5 seconds ago)
// - the default nameservers are used (the last parse could not load
// resolv.conf, or it did not contain any nameservers)
// - rechecks are not disabled (only possible in case of testing)
//
// Note: We only do one check at a time. Other concurrent requests might
// still use the previous (outdated) version of the configuration file.
expired := now.After(conf.lastChecked.Add(5 * time.Second))
rechecksEnabled := conf.lastChecked != distantFuture // for testing purposes
recheck := (expired || dc.isDefaultNS()) && rechecksEnabled
if !recheck {
return
}
conf.lastChecked = nowSo: re-read at most once every five seconds, and only if the file’s mtime
has actually changed. And if the file couldn’t be read at all — the
defaultNS case — it retries on every lookup, because there’s nothing
else to go on.
The fallback servers, for when resolv.conf is missing entirely:
// src/net/dnsconfig.go:25
//go:linkname defaultNS
var defaultNS = []string{"127.0.0.1:53", "[::1]:53"}Localhost, in both families. Which is a nice touch: on a system with no
resolv.conf and no local resolver, the query fails fast rather than
hanging.
The search list
Before querying, Go builds the list of names to try:
// src/net/dnsclient_unix.go:542
// nameList returns a list of names for sequential DNS queries.
func (conf *dnsConfig) nameList(name string) []string {
// Check name length (see isDomainName).
rooted := len(name) > 0 && name[len(name)-1] == '.'
if len(name) > 254 || len(name) == 254 && !rooted {
return nil
}
// If name is rooted (trailing dot), try only that name.
if rooted {
if avoidDNS(name) {
return nil
}
return []string{name}
}
hasNdots := bytealg.CountString(name, '.') >= conf.ndots
name += "."
// Build list of search choices.
names := make([]string, 0, 1+len(conf.search))
// If name has enough dots, try unsuffixed first.
if hasNdots && !avoidDNS(name) {
names = append(names, name)
}
// Try suffixes that are not too long (see isDomainName).
for _, suffix := range conf.search {
fqdn := name + suffix
if !avoidDNS(fqdn) && len(fqdn) <= 254 {
names = append(names, fqdn)
}
}
// Try unsuffixed, if not tried first above.
if !hasNdots && !avoidDNS(name) {
names = append(names, name)
}
return names
}The ndots rule, in plain English: if the name has at least ndots dots
in it, try it as-is first; otherwise try the search suffixes first and the
bare name last. With the default ndots:1, example.com (one dot) is
tried absolutely first, but myservice (no dots) gets the search domains
applied before the bare name.
avoidDNS is the .onion carve-out:
// src/net/dnsclient_unix.go:522
// avoidDNS reports whether this is a hostname for which we should not
// use DNS. Currently this includes only .onion, per RFC 7686. See
// golang.org/issue/13705. Does not cover .local names (RFC 6762),
// see golang.org/issue/16739.
func avoidDNS(name string) bool {
if name == "" {
return true
}
name = stringslite.TrimSuffix(name, ".")
return stringsHasSuffixFold(name, ".onion")
}The query itself
Once we have a name to ask for, tryOneName loops over servers and
attempts:
// src/net/dnsclient_unix.go:297
func (r *Resolver) tryOneName(ctx context.Context, cfg *dnsConfig, name string, qtype dnsmessage.Type) (dnsmessage.Parser, string, error) {
var lastErr error
serverOffset := cfg.serverOffset()
sLen := uint32(len(cfg.servers))and then the retry loop:
// src/net/dnsclient_unix.go:312
for i := 0; i < cfg.attempts; i++ {
for j := uint32(0); j < sLen; j++ {
server := cfg.servers[(serverOffset+j)%sLen]
p, h, err := r.exchange(ctx, server, q, cfg.timeout, cfg.useTCP, cfg.trustAD)
if err != nil {
dnsErr := newDNSError(err, name, server)
// Set IsTemporary for socket-level errors. Note that this flag
// may also be used to indicate a SERVFAIL response.
if _, ok := err.(*OpError); ok {
dnsErr.IsTemporary = true
}
lastErr = dnsErr
continue
}So the iteration order is attempts outer, servers inner. With the
defaults (attempts: 2) and three servers, that’s up to six queries per
name, and each one gets the full timeout (five seconds by default). Worst
case, a name that doesn’t exist can burn 30 seconds.
serverOffset implements the rotate option:
// src/net/dnsconfig.go:54
// serverOffset returns an offset that can be used to determine
// indices of servers in c.servers when making queries.
// When the rotate option is enabled, this offset increases.
// Otherwise it is always 0.
func (c *dnsConfig) serverOffset() uint32 {
if c.rotate {
return atomic.AddUint32(&c.soffset, 1) - 1 // return 0 to start
}
return 0
}An atomic increment, so concurrent lookups spread across the servers instead of all hammering the first one.
exchange is where the query meets the socket:
// src/net/dnsclient_unix.go:168
// exchange sends a query on the connection and hopes for a response.
func (r *Resolver) exchange(ctx context.Context, server string, q dnsmessage.Question, timeout time.Duration, useTCP, ad bool) (dnsmessage.Parser, dnsmessage.Header, error) {
q.Class = dnsmessage.ClassINET
id, udpReq, tcpReq, err := newRequest(q, ad)
if err != nil {
return dnsmessage.Parser{}, dnsmessage.Header{}, errCannotMarshalDNSMessage
}
var networks []string
if useTCP {
networks = []string{"tcp"}
} else {
networks = []string{"udp", "tcp"}
}
for _, network := range networks {
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(timeout))
defer cancel()
c, err := r.dial(ctx, network, server)
if err != nil {
return dnsmessage.Parser{}, dnsmessage.Header{}, err
}
if d, ok := ctx.Deadline(); ok && !d.IsZero() {
c.SetDeadline(d)
}Two details here. First, UDP first, TCP second — TCP is only tried if
UDP fails outright, not if UDP is merely truncated (that’s handled inside
the round trip). Second, the timeout is a context deadline and a socket
deadline, so a hung read can’t outlive the timeout.
The UDP round trip deliberately ignores malformed responses rather than failing:
// src/net/dnsclient_unix.go:109
func dnsPacketRoundTrip(c Conn, id uint16, query dnsmessage.Question, b []byte) (dnsmessage.Parser, dnsmessage.Header, error) {
if _, err := c.Write(b); err != nil {
return dnsmessage.Parser{}, dnsmessage.Header{}, err
}
b = make([]byte, maxDNSPacketSize)
for {
n, err := c.Read(b)
if err != nil {
return dnsmessage.Parser{}, dnsmessage.Header{}, err
}
var p dnsmessage.Parser
// Ignore invalid responses as they may be malicious
// forgery attempts. Instead continue waiting until
// timeout. See golang.org/issue/13281.
h, err := p.Start(b[:n])
if err != nil {
continue
}
q, err := p.Question()
if err != nil || !checkResponse(id, query, h, q) {
continue
}
return p, h, nil
}
}Anything that doesn’t match the query ID and question exactly is treated as if it wasn’t there, and the read loop keeps going until the deadline. A spoofed UDP response doesn’t get you an answer; it just wastes the timeout.
And the truncation rule, straight out of RFC 5966:
// src/net/dnsclient_unix.go:206
// RFC 5966 indicates that when a client receives a UDP response with
// the TC flag set, it should take the TC flag as an indication that it
// should retry over TCP instead.
// The case when the TC flag is set in a TCP response is not well specified,
// so this implements the glibc resolver behavior, returning the existing
// dns response instead of returning a "errNoAnswerFromDNSServer" error.
// See go.dev/issue/64896
if h.Truncated && network == "udp" {
continue
}
return p, h, nilCNAMEs
Here’s something that trips people up: Go’s resolver does not chase CNAMEs. It relies on the recursive resolver having already done the work:
// src/net/dnsclient_unix.go:764
// Presotto says it's okay to assume that servers listed in
// /etc/resolv.conf are recursive resolvers.
//
// We asked for recursion, so it should have included all the
// answers we need in this one packet.
//
// Further, RFC 1034 section 4.3.1 says that "the recursive
// response to a query will be... The answer to the query,
// possibly preface by one or more CNAME RRs that specify
// aliases encountered on the way to an answer."
//
// Therefore, we should be able to assume that we can ignore
// CNAMEs and that the A and AAAA records we requested are
// for the canonical name.The comment even cites “Presotto”, which is to say a well-known argument
from the Go issue tracker. The resolver asks for A and AAAA and reads
the answer section; whatever names come back in the answer records are
treated as the canonical name.
For the explicit LookupCNAME API, the CNAME record itself is picked out
of the answer section:
// src/net/dnsclient_unix.go:823
case dnsmessage.TypeCNAME:
c, err := result.p.CNAMEResource()
if err != nil {
lastErr = &DNSError{
Err: errCannotUnmarshalDNSMessage.Error(),
Name: name,
Server: result.server,
}
break loop
}
if cname.Length == 0 && c.CNAME.Length > 0 {
cname = c.CNAME
}Which is why LookupCNAME returns the first CNAME in the response, not
necessarily the end of a chain.
A and AAAA in parallel
The main lookup fires the A and AAAA queries concurrently, which is one of the nicer things about the pure-Go resolver:
// src/net/dnsclient_unix.go:721
var queryFn func(fqdn string, qtype dnsmessage.Type)
var responseFn func(fqdn string, qtype dnsmessage.Type) result
if conf.singleRequest {
queryFn = func(fqdn string, qtype dnsmessage.Type) {}
responseFn = func(fqdn string, qtype dnsmessage.Type) result {
dnsWaitGroup.Add(1)
defer dnsWaitGroup.Done()
p, server, err := r.tryOneName(ctx, conf, fqdn, qtype)
return result{p, server, err}
}
} else {
queryFn = func(fqdn string, qtype dnsmessage.Type) {
dnsWaitGroup.Add(1)
go func(qtype dnsmessage.Type) {
p, server, err := r.tryOneName(ctx, conf, fqdn, qtype)
lane <- result{p, server, err}
dnsWaitGroup.Done()
}(qtype)
}The single-request resolv.conf option (which glibc also honours) flips
this to sequential. Go implements it because some middleboxes break when
you send A and AAAA at the same time.
The results are then sorted:
// src/net/dnsclient_unix.go:646
sortByRFC6724(addrs)RFC 6724 is the “destination address selection” RFC, which is how you get the “prefer IPv6, but fall back to IPv4” behaviour and the source-address selection rules.
The cgo fallback, precisely
The cgo path is not a fallback after a failed Go lookup. It’s chosen
before the lookup starts, based on configuration. The one exception is
LookupCNAME:
// src/net/lookup_unix.go:87
func (r *Resolver) lookupCNAME(ctx context.Context, name string) (string, error) {
order, conf := systemConf().hostLookupOrder(r, name)
if order == hostLookupCgo {
if cname, err := cgoLookupCNAME(ctx, name); err == nil {
return cname, nil
}
}
return r.goLookupCNAME(ctx, name, order, conf)
}Here, if cgo is selected and cgo fails, Go retries with its own
resolver. That asymmetry is deliberate — LookupCNAME was added late and
the pure-Go path for it was considered less trustworthy.
Otherwise the rule is simple: cgo is used instead of the Go resolver,
never after it. lookupIP:
// src/net/lookup_unix.go:61
func (r *Resolver) lookupIP(ctx context.Context, network, host string) (addrs []IPAddr, err error) {
order, conf := systemConf().hostLookupOrder(r, host)
if order == hostLookupCgo {
return cgoLookupIP(ctx, network, host)
}
ips, _, err := r.goLookupIPCNAMEOrder(ctx, network, host, order, conf)
return ips, err
}Errors
DNS failures come back as *net.DNSError:
// src/net/net.go:670
// DNSError represents a DNS lookup error.
type DNSError struct {
UnwrapErr error // error returned by the [DNSError.Unwrap] method, might be nil
Err string // description of the error
Name string // name looked for
Server string // server used
IsTimeout bool // if true, timed out; not all timeouts set this
IsTemporary bool // if true, error is temporary; not all errors set this
// IsNotFound is set to true when the requested name does not
// contain any records of the requested type (data not found),
// or the name itself was not found (NXDOMAIN).
IsNotFound bool
}Note UnwrapErr: a DNSError from a cancelled context wraps
context.Canceled or context.DeadlineExceeded, so errors.Is(err, context.DeadlineExceeded) works. That’s the modern way to distinguish “the
name doesn’t exist” from “I gave up waiting”.
Trade-offs
What the pure-Go resolver buys you: no cgo, so no C thread, no
cross-language callback, and no runtime.cgocall on every lookup. It works
in a statically-linked binary with no libc at all. It runs the A and AAAA
queries in parallel by default, and it sorts addresses per RFC 6724.
What it costs: it implements only the subset of the resolution stack
that a plain DNS client needs. No mDNS, no NIS, no LDAP, no
myhostname, no /etc/resolv.conf lookup directive on most systems, no
DNSSEC validation, no /etc/hosts aliases beyond the simple form. Every
one of those gaps pushes the lookup to cgo instead.
The retry structure is aggressive. attempts (default 2) times
servers (up to 3) times the timeout (default 5s) is a theoretical 30
seconds per name, per search-list entry. If you have a search list of three
suffixes, that’s 90 seconds. The context deadline is the only thing that
reliably bounds it.
The hosts file cache is five seconds. Long-lived processes will serve a
stale /etc/hosts for up to five seconds after a change. That’s fine for
most workloads and wrong for anything that expects immediate propagation.
Caveats
dig is not a fair comparison. dig bypasses /etc/hosts and
nsswitch.conf entirely and always talks to a server. getent hosts uses
the NSS stack and is a fair comparison for the files,dns order.
The pure-Go resolver is not the default everywhere. If your program
links cgo (or is built with CGO_ENABLED=1 and imports a package that
does), cgoAvailable is true and the default order comes from
nsswitch.conf. With no nsswitch.conf present, you get files,dns and
the pure-Go client.
GODEBUG=netdns=2 prints the decision. The runtime prints the chosen
order for every lookup at debug level 2, which is the fastest way to see
which branch you’re on.
The ndots default is 1, not 0. A single-label name like redis will
get your search domains appended before the bare name is tried, even if
redis would resolve fine. This surprises people with short internal
hostnames.
options unknownOpt disables the whole pure-Go path. One unrecognised
option in resolv.conf means every lookup goes to libc, which changes both
performance and results.
The cgo fallback is not a retry. If libc is selected, Go never runs
its own resolver as a backup (except for LookupCNAME). So a libc
misconfiguration is not something Go will paper over.
Wrapping up
The one thing to remember: Go’s resolver is a strategy chooser first
and a DNS client second. It reads nsswitch.conf, decides between files,
DNS, and cgo, and only then does anything that involves the network.
The part people get wrong: that net.LookupIP is a DNS query. On a
typical Linux box it is a /etc/hosts lookup first, with a five-second
cache, and the DNS query never happens at all if the name is in the hosts
file.
What to go do about it: run your program with GODEBUG=netdns=2 and
compare against getent hosts. If they disagree, the difference is in
nsswitch.conf or in a resolv.conf option Go doesn’t recognise — the
debug output tells you which.
Further reading
src/net/conf.go—lookupOrder,hostLookupOrder, thensswitch.confparser,mustUseGoResolversrc/net/dnsclient_unix.go—goLookupIPCNAMEOrder,lookup,nameList,tryOneName,exchange,dnsPacketRoundTrip,resolverConfig.tryUpdatesrc/net/dnsconfig_unix.go—dnsReadConfig, theresolv.confparsersrc/net/dnsconfig.go— thednsConfigstruct anddefaultNSsrc/net/hosts.go—readHosts,lookupStaticHost,lookupStaticAddr,unchangedHostsFilesrc/net/lookup_unix.go— thelookupIP/lookupHost/lookupCNAMEentry points and the cgo branchessrc/net/lookup.go—LookupIP,LookupHost,LookupCNAMEand the singleflight wrappersrc/net/net.go— theDNSErrortype