When the black box isn't enough
You are building a local development tool that needs to route traffic based on domain names. Or you are debugging a flaky connection and suspect the resolver is caching stale records. The standard library gives you net.LookupIP, but that is a black box. You need to send raw DNS packets, inspect the response codes, or spin up a mock resolver for tests. That is where you drop down to the wire format.
DNS is the protocol that translates names to addresses. Most of the time, you let the operating system handle it. When you write Go code that needs to control the resolution process, you have to speak the protocol directly.
The protocol and the library
Go's standard library handles connections and high-level lookups, but it does not include a full DNS server or client API. The community converged on github.com/miekg/dns. It is not in the stdlib, but it is the de facto library for DNS in Go. Every major DNS tool written in Go uses it.
DNS operates over UDP by default. Queries are tiny, so the protocol treats them like postcards: you write the question, throw it in the mail, and hope a reply comes back. If the response is too large for a single UDP packet, the server sets a truncation bit and the client retries over TCP. The library handles this negotiation automatically.
Go embraces third-party libraries for domain-specific protocols. The standard library stops at the abstraction. When you need protocol details, you reach for the community standard.
Minimal server
Here is the simplest DNS server: listen on UDP, handle the root zone, and reply with an empty response.
package main
import (
"log"
"github.com/miekg/dns"
)
// HandleDNS processes incoming DNS queries and writes a response.
// This function matches the signature required by dns.HandleFunc.
func HandleDNS(w dns.ResponseWriter, r *dns.Msg) {
// Create a new message to hold the response.
m := new(dns.Msg)
// Set the response flags based on the request.
m.SetReply(r)
// Write the message back to the client.
w.WriteMsg(m)
}
func main() {
// Register the handler for all queries (the "." root zone).
dns.HandleFunc(".", HandleDNS)
// Create a server listening on UDP port 53.
server := &dns.Server{Addr: ":53", Net: "udp"}
// Start serving; this blocks until the server stops.
if err := server.ListenAndServe(); err != nil {
log.Fatalf("failed to start server: %v", err)
}
}
dns.HandleFunc registers a callback for a specific zone. The dot . represents the root zone, so it catches every query. The handler receives a dns.ResponseWriter and the parsed dns.Msg. SetReply copies the transaction ID and flags from the request, setting the response bit. WriteMsg serializes the message back to wire format and sends it.
Handlers are just functions. Keep them fast or the resolver queues up.
Minimal client
Here is the client side: construct a query message and exchange it with a resolver.
package main
import (
"fmt"
"log"
"github.com/miekg/dns"
)
func main() {
// Create a client with default settings.
c := new(dns.Client)
// Build a query message for the A record of example.com.
m := new(dns.Msg)
m.SetQuestion("example.com.", dns.TypeA)
// Send the query to Google's public DNS and wait for a response.
r, _, err := c.Exchange(m, "8.8.8.8:53")
if err != nil {
log.Fatalf("exchange failed: %v", err)
}
// Print the answer section if it exists.
if len(r.Answer) > 0 {
fmt.Println(r.Answer[0])
}
}
dns.Client manages the connection. SetQuestion builds the query section. Exchange sends the packet and blocks until a response arrives or the timeout expires. The return values are the response message, the time taken, and an error.
if err != nil is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Always check the error from Exchange.
DNS is UDP by default. Packets drop. Timeouts are your friend.
Anatomy of a message
A DNS message has four sections. The Question section contains the name and type you are looking for. The Answer section holds the records that match the query. The Authority section points to servers that know more about the zone. The Additional section carries extra data, like glue records or EDNS options.
dns.Msg exposes these sections as slices. SetReply initializes the header and copies the question. You populate the other sections by appending structs.
Public names start with a capital letter. dns.A is public. dns.Msg is public. Private fields start lowercase. The library follows Go naming conventions strictly.
Realistic server with answers
Here is a server that returns a specific IP for a local domain.
package main
import (
"net"
"log"
"github.com/miekg/dns"
)
// HandleLocalDNS resolves local.dev to 127.0.0.1.
func HandleLocalDNS(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
// Check if the query is for our specific domain.
if r.Question[0].Name == "local.dev." {
// Add an A record answer pointing to localhost.
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{
Name: "local.dev.",
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: 60,
},
A: net.ParseIP("127.0.0.1"),
})
}
w.WriteMsg(m)
}
func main() {
dns.HandleFunc(".", HandleLocalDNS)
server := &dns.Server{Addr: ":53", Net: "udp"}
if err := server.ListenAndServe(); err != nil {
log.Fatalf("failed to start server: %v", err)
}
}
Constructing an answer requires building a Resource Record. The dns.A struct holds the IP. net.ParseIP converts the string to the byte slice the library expects. The TTL tells caches how long to hold the record.
Accessing r.Question[0] assumes the question section has entries. If the packet is malformed, the slice might be empty. The runtime panics with slice bounds out of range if you index a question section that has no entries. Validate input before indexing. DNS traffic comes from the wild.
Realistic client with context
Here is a client that respects cancellation and wraps errors.
package main
import (
"context"
"fmt"
"time"
"github.com/miekg/dns"
)
// ResolveWithTimeout queries a DNS server and respects a context deadline.
func ResolveWithTimeout(ctx context.Context, domain string, server string) ([]dns.RR, error) {
c := new(dns.Client)
// Set a timeout shorter than the context deadline to fail fast.
c.Timeout = 2 * time.Second
m := new(dns.Msg)
// Ensure the domain ends with a dot for FQDN compliance.
m.SetQuestion(dns.Fqdn(domain), dns.TypeA)
// ExchangeContext respects cancellation and deadlines.
r, _, err := c.ExchangeContext(ctx, m, server)
if err != nil {
return nil, fmt.Errorf("dns exchange failed: %w", err)
}
return r.Answer, nil
}
context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. ExchangeContext checks the context before sending and while waiting. If the context expires, the call returns immediately.
The underscore discards a value intentionally. r, _, err := ... says "I considered the time duration and chose to drop it". Use it sparingly with errors, but freely for values you do not need.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and errors
Port 53 is a privileged port. On Linux, you need root privileges or the CAP_NET_BIND_SERVICE capability to bind to it. The compiler rejects the program with listen udp :53: bind: permission denied if you run as a normal user.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. server.ListenAndServe blocks. If you want to stop the server, you need server.Shutdown.
// Shutdown the server gracefully when the context is cancelled.
ctx, cancel := context.WithCancel(context.Background())
go func() {
// Wait for signal or error to trigger cancellation.
<-ctx.Done()
server.Shutdown(ctx)
}()
server.Shutdown stops accepting new connections and waits for existing handlers to finish. The context passed to Shutdown controls how long to wait.
gofmt is mandatory. Don't argue about indentation; let the tool decide. Most editors run it on save. The community trusts the formatter so developers can argue logic, not formatting.
The receiver name is usually one or two letters matching the type: (m *MockResolver), not (this *MockResolver). This convention keeps method signatures clean and consistent across the ecosystem.
The worst goroutine bug is the one that never logs. Always log errors in background goroutines.
Decision matrix
Use net.LookupIP when you just need to resolve a hostname and don't care about the protocol details. Use miekg/dns client when you need to inspect response codes, handle EDNS, or query specific record types like TXT or SRV. Use miekg/dns server when you are building a resolver, a mock for tests, or a DNS-over-HTTPS proxy. Use a high-level HTTP library when your service communicates over HTTP and DNS is just a transport detail handled by the OS.