---
title: How to Handle DNS Resolution in Rust
summary: Use external crates like hickory-resolver to handle DNS resolution in Rust since the standard library lacks built-in support.
lang: en
slug: how-to-handle-dns-resolution-in-rust
published: 2026-04-17T12:05:42+00:00
updated: 2026-04-25T22:00:13+00:00
canonical: https://www.rustfaq.org/en/how-to-handle-dns-resolution-in-rust/
---

# How to Handle DNS Resolution in Rust



## 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.

```toml
[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.

```rust
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.

```rust
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.

```rust
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`.

```rust
// 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.

```rust
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.

## Where to go next

- [Overview of Web Frameworks in Rust: Axum vs Actix vs Rocket](/en/overview-of-web-frameworks-in-rust-axum-vs-actix-vs-rocket/)
- [How to Use Rust WASM for Serverless Functions (Cloudflare Workers, Fastly)](/en/how-to-use-rust-wasm-for-serverless-functions-cloudflare-workers-fastly/)
- [How to Deploy Rust WASM Applications](/en/how-to-deploy-rust-wasm-applications/)