> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dnzwithazee.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Benchmarks

> How fast DNZ answers compared with 18 other DNS servers, from Knot DNS, NSD, BIND 9 and PowerDNS to Technitium, AdGuard Home and Pi-hole, which features each one has built in, how much memory DNZ uses, and how to measure it yourself.

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:

| Scenario | Servers |
| - | - |
| Authoritative | DNZ, NSD, Knot DNS, BIND 9, gdnsd, PowerDNS Authoritative, Unbound, YADIFA, CoreDNS, Technitium, Hickory DNS |
| Cached forwarding | DNZ, PowerDNS Recursor, BIND 9, dnsdist, Knot Resolver, Unbound, CoreDNS, dnsmasq, Technitium, AdGuard Home, Pi-hole, Hickory DNS, Blocky |

For dnz-proxy against nginx, HAProxy, Envoy, Caddy and Traefik, see [Proxy benchmarks](https://docs.dnzwithazee.com/proxy/benchmarks.md).

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

| Server | Queries/s | Avg latency | Memory |
| - | -: | -: | -: |
| Knot DNS 3.4.6 | 142,495 | 0.63 ms | 8.4 MiB |
| NSD 4.12.0 | 140,581 | 0.73 ms | 77.0 MiB |
| **DNZ 1.6.0** | **135,136** | **1.12 ms** | **40.1 MiB** |
| PowerDNS Authoritative 4.9.17 | 131,820 | 0.93 ms | 66.0 MiB |
| gdnsd 3.8.3 | 124,645 | 0.66 ms | 3.4 MiB |
| BIND 9.20.30 | 124,072 | 1.32 ms | 17.3 MiB |
| Unbound 1.26.1 (`auth-zone`) | 120,496 | 0.81 ms | 18.6 MiB |
| YADIFA 3.0.2 ¹ | 80,889 | 1.62 ms | 71.7 MiB |
| CoreDNS 1.12.1 (`file`) | 53,201 | 3.84 ms | 81.6 MiB |
| Technitium 15.6.2 | 39,138 | 10.93 ms | 151.4 MiB |
| Hickory DNS 0.26.3 | 11,833 | 15.07 ms | 21.0 MiB |

### Cached forwarding

| Server | Queries/s | Avg latency | Memory |
| - | -: | -: | -: |
| dnsdist 1.9.16 (packet cache) | 146,251 | 1.57 ms | 46.8 MiB |
| BIND 9.20.30 | 145,324 | 0.91 ms | 22.1 MiB |
| PowerDNS Recursor 5.2.13 | 139,004 | 1.66 ms | 29.4 MiB |
| Knot Resolver 5.7.5 | 134,637 | 0.73 ms | 9.8 MiB |
| **DNZ 1.6.0** | **131,156** | **1.19 ms** | **62.1 MiB** |
| Unbound 1.26.1 | 125,658 | 0.67 ms | 26.2 MiB |
| CoreDNS 1.12.1 | 68,451 | 2.94 ms | 94.4 MiB |
| dnsmasq 2.91 | 65,261 | 2.51 ms | 5.0 MiB |
| Technitium 15.6.2 | 37,650 | 11.52 ms | 179.0 MiB |
| AdGuard Home 0.107.79 | 31,253 | 7.01 ms | 74.2 MiB |
| Pi-hole 2026.10.0 | 21,991 | 7.52 ms | about 44 MiB ² |
| Hickory DNS 0.26.3 | 19,965 | 8.66 ms | 38.3 MiB |
| Blocky 0.35.0 | 14,482 | 15.25 ms | 78.2 MiB |

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:

| DNZ | Queries/s (median) | Avg latency (median) |
| - | -: | -: |
| Before | 181,945 | 0.87 ms |
| After | 261,893 | 0.53 ms |

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:

| | v1.5.0 | First step |
| - | -: | -: |
| Authoritative, queries/s | 69,381 | 104,169 |
| Cached forwarding, queries/s | 64,311 | 103,045 |
| Names not in a full cache, queries/s | 290 | 14,000 |
| Their average latency | 665 ms | 14 ms |

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:

| | Before | After |
| - | -: | -: |
| Authoritative, queries/s | 99,790 | 136,002 |
| Cached forwarding, queries/s | 103,984 | 132,411 |
| Average latency | 1.6-1.9 ms | 1.1-1.5 ms |
| CPU per query at a steady 80,000/s | 23.9-24.3 µs | 20.9 µs |

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

| Server | Authoritative | Forwarding + cache | Recursive | DNSSEC signing | DNSSEC validation | Encrypted DNS | Zone transfers | Dynamic updates | GeoDNS / views | Filtering |
| - | - | - | - | - | - | - | - | - | - | - |
| **DNZ** | ✓ | ✓ | ✓ | ✓ | ✓ | DoT, DoH, DoQ, DoH3 | ✓, catalog zones | ✓ | geo + views | lists + RPZ |
| NSD | ✓ | – | – | pre-signed zones | – | DoT | ✓ | – | – | – |
| Knot DNS | ✓ | – | – | ✓ | – | DoT, DoQ | ✓, catalog zones | ✓ | geo + views | – |
| BIND 9 | ✓ | ✓ | ✓ | ✓ | ✓ | DoT, DoH | ✓, catalog zones | ✓ | geo + views | RPZ |
| gdnsd | ✓ | – | – | – | – | – | – | – | geo | – |
| PowerDNS Authoritative | ✓ | – | – | ✓ | – | – | ✓, catalog zones | ✓ | geo (LUA records) | – |
| YADIFA | ✓ | – | – | ✓ | – | DoT (experimental) | ✓ | ✓ | – | – |
| Unbound | local zones | ✓ | ✓ | – | ✓ | DoT, DoH, DoQ | as secondary | – | views | RPZ |
| Knot Resolver | – | ✓ | ✓ | – | ✓ | DoT, DoH | – | – | views | RPZ |
| PowerDNS Recursor | local zones | ✓ | ✓ | – | ✓ | – | – | – | – | RPZ |
| dnsdist | – | ✓ | – | – | – | DoT, DoH, DoQ, DoH3 | – | – | rules | rules |
| dnsmasq | limited | ✓ | – | – | ✓ | – | limited | – | – | hosts files |
| CoreDNS | ✓ | ✓ | – | ✓ | – | DoT, DoH, DoQ | ✓ | – | views | – |
| Hickory DNS | ✓ | ✓ | experimental | ✓ | ✓ | DoT, DoH, DoQ, DoH3 | AXFR out | ✓ | – | lists |
| Technitium | ✓ | ✓ | ✓ | ✓ | ✓ | DoT, DoH, DoQ | ✓, catalog zones | ✓ | geo + views (apps) | lists |
| AdGuard Home | rewrites only | ✓ | – | – | – | DoT, DoH, DoQ | – | – | – | lists |
| Pi-hole | local records | ✓ | – | – | ✓ | – | – | – | – | lists |
| Blocky | custom mappings | ✓ | – | – | ✓ | DoT, DoH | – | – | – | lists |

### Running it

| Server | Web UI | Query log | HTTP API | Users and roles | Prometheus | Several servers, one config | DHCP |
| - | - | - | - | - | - | - | - |
| **DNZ** | ✓ | searchable, in the database | ✓ | per division (RBAC) | ✓ | shared Postgres or MySQL | names and IPAM from leases |
| NSD | – | – | – | – | exporter | – | – |
| Knot DNS | – | dnstap | – | – | exporter | – | – |
| BIND 9 | – | to a file | statistics only | – | exporter | – | – |
| gdnsd | – | – | – | – | – | – | – |
| PowerDNS Authoritative | statistics page | to the log | ✓ | – | ✓ | shared SQL backend | – |
| YADIFA | – | – | – | – | – | – | – |
| Unbound | – | to the log | – | – | exporter | – | – |
| Knot Resolver | statistics page | dnstap | – | – | ✓ | – | – |
| PowerDNS Recursor | statistics page | protobuf, dnstap | ✓ | – | ✓ | – | – |
| dnsdist | statistics page | protobuf, dnstap | ✓ | – | ✓ | – | – |
| dnsmasq | – | to the log | – | – | – | – | ✓ |
| CoreDNS | – | to the log | – | – | ✓ | etcd, Kubernetes | – |
| Hickory DNS | – | to the log | – | – | build option | – | – |
| Technitium | ✓ | ✓ (app) | ✓ | ✓ | ✓ | ✓ (cluster) | ✓ |
| AdGuard Home | ✓ | ✓ | ✓ | admins only | – | – | ✓ |
| Pi-hole | ✓ | ✓ | ✓ | one password | – | – | ✓ |
| Blocky | – | database or CSV | ✓ | – | ✓ | Redis sync | – |

### Operations

| Server | Health-checked answers | Zone history + rollback | Audit log | Alerts | Scheduled backups | Threat detection | Reverse proxy | Cloudflare sync |
| - | - | - | - | - | - | - | - | - |
| **DNZ** | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| NSD | – | – | – | – | – | – | – | – |
| Knot DNS | – | – | – | – | – | – | – | – |
| BIND 9 | – | – | – | – | – | – | – | – |
| gdnsd | ✓ | – | – | – | – | – | – | – |
| PowerDNS Authoritative | ✓ (LUA records) | – | – | – | – | – | – | – |
| YADIFA | – | – | – | – | – | – | – | – |
| Unbound | – | – | – | – | – | – | – | – |
| Knot Resolver | – | – | – | – | – | – | – | – |
| PowerDNS Recursor | – | – | – | – | – | – | – | – |
| dnsdist | – | – | – | – | – | – | – | – |
| dnsmasq | – | – | – | – | – | – | – | – |
| CoreDNS | – | – | – | – | – | – | – | – |
| Hickory DNS | – | – | – | – | – | – | – | – |
| Technitium | ✓ (Failover app) | – | in the server log | – | manual only | – | – | – |
| AdGuard Home | – | – | – | – | – | – | – | – |
| Pi-hole | – | – | – | – | manual only (Teleporter) | – | – | – |
| Blocky | – | – | – | – | – | – | – | – |

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

| Server | Built in, of 25 |
| - | -: |
| **DNZ** | **25** |
| Technitium | 19 |
| BIND 9 | 12 |
| PowerDNS Authoritative | 11 |
| Hickory DNS | 11 |
| Unbound | 9 |
| Knot Resolver | 9 |
| PowerDNS Recursor | 9 |
| CoreDNS | 9 |
| AdGuard Home | 9 |
| Pi-hole | 9 |
| Blocky | 9 |
| dnsdist | 8 |
| Knot DNS | 7 |
| dnsmasq | 7 |
| YADIFA | 5 |
| NSD | 3 |
| gdnsd | 3 |

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](https://www.dns-oarc.net/tools/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.

| Phase | Before | After |
| - | -: | -: |
| Idle after start | 37.3 MB | 27.8 MB |
| After the import | 86.4 MB | 85.8 MB |
| Authoritative load | 95.9 MB | 64.3 MB |
| Cached forwarding load | 115.4 MB | 84.4 MB |
| Idle, a minute later | 117.3 MB | 72.9 MB |

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.

| Phase | Before | After |
| - | -: | -: |
| After the zone import | 69 MB | 68 MB |
| After adding the lists and the feed | 195 MB | 101 MB |
| After eight refreshes | 255 MB | 134 MB |
| After ten minutes of load | 363 MB | 244 MB |
| Idle, a minute later | 351 MB | 231 MB |

* **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](https://docs.dnzwithazee.com/dns/threats.md) 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:

| Scenario | Detection on (median) | Detection off (median) |
| - | -: | -: |
| Authoritative | 27,056 q/s | 26,698 q/s |
| Cached forwarding | 70,465 q/s | 71,826 q/s |

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/`](https://github.com/terrific-ci/dnz/tree/main/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:

```bash theme={null}
curl -fsSL https://github.com/terrific-ci/dnz/archive/refs/heads/main.tar.gz \
  | tar -xz --strip-components=1 dnz-main/bench
cd bench
./run.sh
```

| Variable | Default | What it sets |
| - | - | - |
| `DNZ_IMAGE` | `terrificci/dnz:latest` | The DNZ image to measure, e.g. `terrificci/dnz:1.5.0` |
| `BENCH_SERVERS` | all | Only these servers, e.g. `"dnz knot"` |
| `BENCH_DURATION`, `BENCH_RUNS` | `20`, `3` | Seconds per run, and runs per scenario |
| `BENCH_CPUS`, `BENCH_PERF_CPUS` | `2`, `2` | CPUs per server, and for dnsperf |
| `BENCH_KEEP` | off | Leave the containers running afterwards |

```bash theme={null}
BENCH_DURATION=60 BENCH_RUNS=5 BENCH_CPUS=4 ./run.sh
```

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.