Skip to main content
DNZ was measured against 18 other DNS servers, from the classic authoritative servers to the home-network filters, each in the scenarios it supports, and compared feature by feature below: For dnz-proxy against nginx, HAProxy, Envoy, Caddy and Traefik, see Proxy benchmarks.

Results

Measured on 2026-10-23 on a dedicated server: a KVM virtual machine with 4 CPUs (Intel Xeon Silver 4114, 2.2 GHz), x86_64, nothing else running. 2 CPUs and 1 GiB per server, dnsperf on the other 2 CPUs over UDP, 32 clients, 500 queries outstanding, median of 3 runs of 20 s.

Authoritative

Cached forwarding

DNZ is the release image terrificci/dnz:1.6.0. v1.5.0, measured the same morning, answered 66,772 authoritative and 65,581 cached queries a second at 2.3 ms; what changed is below.
  1. YADIFA 3.0.2 (the Debian 13 package) sends malformed NXDOMAIN answers (the SOA in the wrong section), one query in ten here, so its number is not quite comparable.
  2. Not captured in this run; a 3-second trial run of the same harness showed 43.7 MiB.
What the numbers say:
  • The top group is the load generator’s limit. Knot DNS, NSD, DNZ, PowerDNS, gdnsd, BIND 9, dnsdist, Knot Resolver and Unbound all land between 120k and 146k queries a second, within 21% of each other. That is what dnsperf sends from its 2 CPUs; their real capacity is higher, and this run doesn’t rank them among themselves.
  • DNZ is in that group in both scenarios, while writing every query to its query log, with 1.1 to 1.2 ms of latency. v1.5.0 answered half as many at twice the latency (below). DNZ answers 2.5 times what CoreDNS does authoritative and 1.9 times CoreDNS and dnsmasq cached.
  • Against the servers with a web UI, a query log and filtering, the ones people often pick instead of DNZ: Technitium answers 29% of what DNZ does, AdGuard Home 24%, Pi-hole 17% and Blocky 11%, with 6 to 13 times DNZ’s latency.
  • Hickory DNS, the other DNS server written in Rust, answers 9% of DNZ’s authoritative and 15% of its cached queries (with its default info logging of every request).
  • Memory: the C servers that only answer DNS mostly hold 3 to 30 MiB; NSD, PowerDNS Authoritative and YADIFA set aside 66 to 77 MiB. DNZ holds 40 to 62 MiB with its web API, query log and cache: less than AdGuard Home, Blocky and CoreDNS here, a third of Technitium.

Cache hits, before and after

DNZ used to keep cached answers as parsed messages behind one lock and encode each hit again. It now keeps them in wire format in a map split into shards, and a hit only copies the bytes and patches the id, the question and the TTLs. Both builds side by side, taking turns for six rounds of 20 s: About 44% more cached answers per second on the same two CPUs, ahead in every round.

From v1.5.0 to v1.6.0: UDP, a full cache and the query log

Profiling v1.5.0 on the benchmark server turned up three things, fixed in v1.6.0:
  • UDP: one loop received every query and handed each to another thread. DNZ now binds the DNS port several times per CPU (SO_REUSEPORT, Linux) and answers local names and cache hits on the thread that received them.
  • A full cache: every answer from upstream swept the whole cache for one to drop, 3 ms per insert with 100,000 entries, so names not yet cached ran at under 300 a second. Now 1.5 µs.
  • Memory that kept climbing: the query log’s batches were written as one statement per batch size, and the database driver kept up to 100 of them prepared per connection. Under steady load the heap grew from 52 to 240 MB over 20 minutes; it now levels off within minutes.
Both builds side by side, the settings above, taking turns: Ahead in every round (5 of 5 for authoritative and cached, 3 of 3 for the misses).

Then: UDP in batches

Three more changes, again found with perf on the benchmark server:
  • Less state per query: answering a query carried 22 KB of state, sized for its rarest paths, allocated and copied every time. Those paths keep their own now, and a query’s is 2 KB.
  • The name once: a local answer wrote the query name out four times; now once, with a third fewer allocations.
  • Batches: on Linux each socket reads the queries waiting on it with one recvmmsg (up to 32) and sends the answers with one sendmmsg, instead of a system call per query each way. A busy socket hands its thread over after 128 queries, so the others aren’t kept waiting.
The build before and after, side by side, the settings above, taking turns: Ahead in every round (5 of 5 for each scenario). That is the top group above, at what dnsperf sends from its 2 CPUs, with DNZ’s query log still on.

Features compared

Speed is half the picture: the servers ahead of DNZ answer DNS and little else, and the ones with a comparable feature set (Technitium, AdGuard Home, Pi-hole, Blocky) answer 2 to 4 times fewer queries. What each server has built in, for the versions measured above, from each project’s own documentation. A dash means not built in; much of it is possible with a separate tool (PowerDNS-Admin for a PowerDNS UI, an exporter for Prometheus, dnsdist in front for encrypted DNS).

DNS

Running it

Operations

Counted up

Every cell above that isn’t a dash counts, limited ones included (local zones, AXFR out, a statistics page, a query log written to the log); an external exporter, pre-signed zones and a manual backup don’t. Technitium comes closest, and it has a DHCP server of its own, which DNZ doesn’t: DNZ takes names from the DHCP server you already run. The servers that beat DNZ on queries a second have 3 to 12 of the 25.

Method

  • Every server runs in its own container on one Docker network, with the same CPU and memory limits; only the server being measured is busy. The load generator is dnsperf in another container.
  • The classic servers are the Debian 13 packages; CoreDNS, Technitium, AdGuard Home, Pi-hole and Blocky the official images; Hickory DNS a release build from crates.io; DNZ a release build.
  • Authoritative: a zone of 10,000 A records, each queried in turn, with one query in ten for a name that does not exist. DNZ and Technitium get the zone through their import APIs like a user would; the others read the zone file.
  • Cached forwarding: 10,000 names of another zone, which the resolvers forward to a Knot DNS container. One warm-up pass fills the caches, so the measured runs are all cache hits. Nobody validates DNSSEC here (DNZ’s default for forwarding).
  • Defaults otherwise, with three exceptions, because all queries come from one address: rate limits are off (DNZ’s, Technitium’s, AdGuard Home’s, Pi-hole’s, and response rate limiting in NSD and YADIFA); caches have room for every name; servers that take a thread count get one per CPU. Each server keeps its default logging: DNZ writes every query to its query log, as Pi-hole, AdGuard Home and Blocky do; Technitium keeps statistics.

Memory

How much memory DNZ holds through a day’s work: the release image with its defaults (web UI included) on 2 CPUs, idle after start, after importing a zone of 10,000 records, under authoritative and cached query load, and idle a minute later. The figure is the resident set (RSS); about 20 MB of it is the dnz binary itself, which the kernel can drop and read again. On 4 CPUs: 30.3 MB at idle instead of 48.5 MB, and 59.6 MB a minute after the load instead of 155.9 MB. Each extra CPU adds about 1.2 MB. Query throughput is the same as before.
  • The web server’s routes no longer compile into full regexes once per CPU.
  • On Linux, DNZ uses one malloc arena per CPU and returns freed memory to the system every minute, so the minute after a large import or a burst of queries is the high point, not the new normal.
  • The query log keeps every query for 7 days by default (DNZ_LOG_RETENTION_DAYS), so the database file grows until then. Tools that report a container’s memory including the kernel’s page cache show that file too; the kernel gives that memory back when something else needs it.
  • Zones kept in memory no longer reserve room for four records under every name.

With blocklists and a threat feed

Blocklists and threat feeds are most of what a filtering DNZ holds. The same zone and load as the UDP sections above, plus HaGeZi Pro and OISD big as blocklists (361,000 domains together) and the HaGeZi Threat Intelligence feed (2 million hosts), each refreshed eight times, then ten minutes of mixed load at 4,000 queries a second. The figure is the heap (anonymous resident memory); measured on 2026-10-24 on the benchmark server: before is v1.6.1’s code, after has the changes below, released in v1.7.0.
  • Domains packed together. Every domain was a heap block of its own in a hash set, 58 to 69 bytes each. Now a list’s domains sit one after another in one buffer with a table of 4-byte positions, about 28 bytes each. The 2-million-host feed went from 132 MB to 54 MB on its own.
  • Large inserts not kept prepared. Lists go into the database 5,000 rows at a time, and the database driver kept each such statement prepared: megabytes each, on each of eight connections, and one more whenever a refresh ended on a chunk of a new size.
  • Cached answers without spare room. Each cached answer kept a 512-byte buffer; most need 100 to 200 bytes.
Finding a name got faster too. Side by side with the lists and the feed loaded on both, six rounds of 20 s, the new build was ahead in every round: 123,154 against 109,673 authoritative and 118,956 against 101,559 cached queries a second (medians), with lower latency. At a steady 80,000 queries a second both spent the same CPU time per query.

Threat detection on and off

Threat detection looks at a copy of the query log, on its own task. The same release build ran twice side by side, detection on and off, taking turns for six rounds of 20 seconds per scenario on a busy MacBook: The same within the noise (+1% and −2%); detection looked at all 11.3 million queries, dropped none, and held about 5 MB more.

Run it yourself

The harness is in bench/ on the public repository. It needs Docker and runs DNZ from the release image on Docker Hub (terrificci/dnz:latest), next to the other servers’ official images and Debian packages:
run.sh prints the results table for your machine, with the versions it ran. Recursion from the root is not part of it: a cold recursive lookup depends on the internet far more than on the resolver, and a cached one is the cached forwarding case. The memory table comes from ./memory.sh in the same folder (MEM_CPUS=4 for 4 CPUs, MEM_IMAGE=terrificci/dnz:1.5.0 for a given release).