Why the standard library isn't enough
You're writing a client that connects to api.example.com. You have the hostname. You need the IP address to open a socket. In Python, you call socket.gethostbyname and get a string. In JavaScript, dns.lookup gives you a callback. Rust's standard library has std::net::ToSocketAddrs, which works for simple cases. It delegates to the operating system's resolver. That approach blocks the current thread until the DNS query finishes. If you're building an async application, blocking the thread kills your throughput. The event loop stalls while the OS talks to a DNS server. You also lose control over caching, timeouts, and which DNS servers are used. The standard library gives you a black box. Third-party crates implement the DNS protocol directly, giving you async support, configurable caching, and full visibility into the resolution process.
The hickory-resolver crate
The community standard for DNS in Rust is hickory-resolver. It used to be called trust-dns-resolver. The project was renamed, and the old crates are archived. Search results might still show trust-dns. Ignore those. hickory-resolver is the active, maintained version. It supports both synchronous and asynchronous resolution. It works with Tokio, async-std, smol, and any runtime that implements the AsyncIo trait. It reads system configuration by default, so your app respects /etc/resolv.conf on Linux or the registry on Windows. It caches results in memory to avoid redundant network queries. It handles retries, timeouts, and IPv6 fallback automatically.
Add the crate to your Cargo.toml. You'll also need a runtime if you're doing async resolution.
[dependencies]
hickory-resolver = "0.24"
tokio = { version = "1", features = ["full"] }
The version number moves. Check crates.io for the latest stable release. The API is stable, but minor version bumps happen.
Minimal async example
Here's a complete example using Tokio. It resolves a hostname and prints the first IP address.
use hickory_resolver::TokioAsyncResolver;
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
/// Resolves a hostname and prints the first IP address.
#[tokio::main]
async fn main() {
// Load system resolver config (e.g., /etc/resolv.conf)
let config = ResolverConfig::default();
// Default options enable caching and reasonable timeouts
let opts = ResolverOpts::default();
// Create the async resolver tied to Tokio
let resolver = TokioAsyncResolver::tokio(config, opts);
// Perform the lookup asynchronously
let lookup = resolver.lookup_ip("www.rust-lang.org").await.unwrap();
// Get the first IP from the results
if let Some(ip) = lookup.iter().next() {
println!("Found IP: {}", ip);
}
}
ResolverConfig::default() reads your system's DNS configuration. This ensures your Rust app uses the same nameservers as the rest of the system. ResolverOpts::default() turns on caching. The first lookup hits the network. Subsequent lookups for the same domain come from memory. TokioAsyncResolver::tokio creates a resolver that uses Tokio's I/O drivers. It sends UDP packets without blocking threads. The lookup_ip method returns a Lookup object containing all matching IP addresses. You iterate over them to pick one.
Convention aside: The community prefers TokioAsyncResolver::tokio over the generic AsyncResolver::new when you're using Tokio. The specific constructor is clearer and avoids trait bound noise.
How the resolver works
DNS resolution involves sending queries to nameservers and parsing the responses. hickory-resolver handles the protocol details. You provide a hostname; it returns a Lookup. The Lookup object holds multiple records. A domain can have several IPv4 addresses, several IPv6 addresses, or both. The Lookup preserves all of them. You decide which one to use.
The resolver caches results based on the Time-To-Live (TTL) value returned by the DNS server. If a record has a TTL of 300 seconds, the resolver caches it for five minutes. Requests during that window hit the cache. This reduces network traffic and latency. The cache is bounded by ResolverOpts::cache_size. The default is 256 entries. If you're building a crawler or a high-throughput proxy, bump that number.
use hickory_resolver::config::ResolverOpts;
/// Creates options with a larger cache for high-throughput apps.
fn high_throughput_opts() -> ResolverOpts {
let mut opts = ResolverOpts::default();
// Increase cache to hold more domain entries
opts.cache_size = 4096;
// Reduce timeout for faster failure detection
opts.timeout = std::time::Duration::from_secs(2);
opts
}
The timeout option controls how long the resolver waits for a response. The default is usually around 5 seconds. Lowering it makes your app fail faster when DNS servers are unresponsive. Raising it helps in high-latency environments. The tries option controls how many times the resolver retries a failed query. The default is 3.
Realistic example: Custom nameservers and error handling
Sometimes you need to use a specific DNS server. Maybe you're in a corporate network with internal domains. Maybe you want to use Cloudflare's 1.1.1.1 for privacy. You can configure the resolver to use custom nameservers.
use hickory_resolver::TokioAsyncResolver;
use hickory_resolver::config::{ResolverConfig, ResolverOpts, NameServerConfig};
use std::net::SocketAddr;
/// Resolves a hostname using a custom DNS server.
/// Returns the first IP address or an error.
async fn resolve_custom(hostname: &str) -> Result<std::net::IpAddr, Box<dyn std::error::Error>> {
// Start with a fresh config
let mut config = ResolverConfig::new();
// Add Cloudflare's DNS server
let cloudflare = SocketAddr::from(([1, 1, 1, 1], 53));
config.add_name_server(NameServerConfig::new(cloudflare, hickory_resolver::config::Protocol::Udp));
let opts = ResolverOpts::default();
let resolver = TokioAsyncResolver::tokio(config, opts);
// Perform lookup, propagating errors
let lookup = resolver.lookup_ip(hostname).await?;
// Return the first IP or an error if empty
lookup.iter().next().map(|ip| *ip).ok_or_else(|| "No IP addresses found".into())
}
ResolverConfig::new() creates an empty configuration. You add nameservers manually. NameServerConfig::new takes a socket address and a protocol. UDP is standard for DNS. TCP is used for large responses or zone transfers. The resolver falls back to TCP automatically if UDP responses are truncated.
Error handling matters. lookup_ip returns a Result. The error type includes details about timeouts, network failures, and DNS errors like NXDOMAIN. Use ? to propagate errors up the call stack. Don't unwrap in production code.
Pitfalls and compiler errors
If you forget .await on the resolver call, the compiler rejects you with E0277 (trait bound not satisfied). The error message complains that the future isn't resolved. The fix is to add .await or wrap the call in a function marked async.
// This fails to compile
let lookup = resolver.lookup_ip("example.com");
// Error[E0277]: `LookupFuture` is not a future
If you use std::net::ToSocketAddrs inside a Tokio task, you block the thread. Tokio detects this and prints a warning. The warning mentions "blocking on a single-threaded runtime". The fix is to use the async resolver. If you must use blocking code, wrap it in tokio::task::spawn_blocking. That moves the work to a separate thread pool. It's safer than blocking the event loop directly.
The Lookup object holds references to cached data. You can't hold a Lookup across .await points if the resolver is shared. The borrow checker prevents this. Clone the IP addresses if you need them later.
let lookup = resolver.lookup_ip("example.com").await?;
let ips: Vec<_> = lookup.iter().collect();
// Now you can await other things
other_async_call().await;
// Use ips here
Convention aside: hickory-resolver uses Lookup to avoid copying data. Collecting into a Vec is the standard way to own the results. The community expects you to handle the lifetime of Lookup carefully.
Decision matrix
Use std::net::ToSocketAddrs when you're writing a simple synchronous binary and don't need caching or custom servers. It delegates to the OS and requires no dependencies.
Use hickory-resolver with TokioAsyncResolver when you're building an async application and need non-blocking DNS lookups. It integrates with the event loop and supports caching.
Use hickory-resolver with AsyncResolver when you're using a runtime other than Tokio, like async-std or smol. The generic constructor accepts any AsyncIo implementation.
Use hickory-resolver with SystemConfig when you need to inspect or modify the resolver configuration programmatically. It exposes the parsed nameservers and search domains.
Reach for the HTTP client's built-in resolver when you're using reqwest or hyper and don't need DNS outside of HTTP requests. These clients handle resolution internally.
Don't fight the compiler here. Reach for hickory-resolver when the standard library blocks your progress.