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.LookupIP doesn’t just “do DNS”. It picks a strategy from /etc/nsswitch.conf, tries /etc/hosts first (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:

text
   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:

go
// 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:

go
// 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:

  1. Resolver.PreferGo is set, or cgo isn’t available — you get the pure-Go resolver with a fallback order of files,dns.
  2. GODEBUG=netdns=cgo — you get libc, full stop.
  3. Neither — the runtime reads /etc/nsswitch.conf and figures it out.

In the third case, the runtime reads the hosts: line of nsswitch.conf and walks the listed sources in order:

go
// 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”:

go
// 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:

go
// 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:

go
// 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:

go
// src/net/hosts.go:17
const cacheMaxAge = 5 * time.Second

The invalidation logic is the interesting part, because a naive “check the mtime” is wrong in a specific way:

go
// 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:

go
// 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:

  1. Cache still fresh — return immediately, don’t even stat.
  2. File looks unchanged — extend the expiry by five seconds without re-reading.
  3. 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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 = true

Two 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:

go
// 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:

go
// 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:

go
// 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 = now

So: 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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, nil

CNAMEs

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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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, the nsswitch.conf parser, mustUseGoResolver
  • src/net/dnsclient_unix.go — goLookupIPCNAMEOrder, lookup, nameList, tryOneName, exchange, dnsPacketRoundTrip, resolverConfig.tryUpdate
  • src/net/dnsconfig_unix.go — dnsReadConfig, the resolv.conf parser
  • src/net/dnsconfig.go — the dnsConfig struct and defaultNS
  • src/net/hosts.go — readHosts, lookupStaticHost, lookupStaticAddr, unchangedHostsFile
  • src/net/lookup_unix.go — the lookupIP/lookupHost/lookupCNAME entry points and the cgo branches
  • src/net/lookup.go — LookupIP, LookupHost, LookupCNAME and the singleflight wrapper
  • src/net/net.go — the DNSError type

GoInternalsDNSNetworkingResolvers