DNS can return useful information by saying that data is absent. NXDOMAIN reports a name error; NODATA describes a successful response without the requested record type. In a direct query without aliases, these distinguish a nonexistent name from an existing name that lacks the requested data. Caching those results avoids repeating the same unsuccessful upstream lookup for every client request.

When it breaks

A deployment can create a correct record and still appear broken to clients whose resolver previously cached its absence. The authority returns the new data, while the cache continues returning the earlier negative result until its lifetime ends. Repeatedly recreating the record does not invalidate that remote cache. This is especially visible when applications query a planned hostname before provisioning completes.

An empty answer section alone is not enough to classify a DNS result. Examine the response code, authority section, and any alias chain. A referral is different from an authoritative negative answer, and an NXDOMAIN can concern a terminal CNAME target rather than the originally requested alias. Reporting every empty result as “the domain does not exist” can send investigation toward the wrong record.

For authoritative NXDOMAIN and NODATA covered by RFC 2308, negative lifetime comes from the smaller of SOA.MINIMUM and the SOA record’s TTL. That value is carried on the SOA in the negative response and aged by caches. The MINIMUM field is not a universal floor for all positive record TTLs, and changing only a future positive record’s TTL does not shorten an already cached absence.

Check it quickly

Save this as check-negative-dns.py and run python3 check-negative-dns.py with Docker. The pinned environment uses BIND 9.18.49 and dnsmasq 2.90 on isolated loopback networking. The first zone has SOA TTL 8, SOA minimum 4, and one existing A record. The example checks actual responses and authoritative query counts before and after creating the missing data:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends bind9=1:9.18.49-1~deb12u2 bind9-dnsutils=1:9.18.49-1~deb12u2 dnsmasq-base=2.90-4~deb12u2 python3-dnspython=2.3.0-1 && rm -rf /var/lib/apt/lists/*\nCMD ["/usr/bin/python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,re,signal,subprocess,time
import dns.exception,dns.message,dns.query,dns.rcode,dns.rdatatype

def run(*args,check=True):
    result=subprocess.run(args,capture_output=True,text=True,timeout=15)
    if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
    return result
def query(name,kind='A',port=1053):
    return dns.query.udp(dns.message.make_query(name,kind),'127.0.0.1',port=port,timeout=1)
def values(response,kind='A'):
    wanted=dns.rdatatype.from_text(kind)
    return [item.to_text() for rrset in response.answer if rrset.rdtype==wanted for item in rrset]
def show(response):
    def section(records):return [{'name':str(rr.name),'type':dns.rdatatype.to_text(rr.rdtype),'ttl':rr.ttl,'data':[item.to_text() for item in rr]} for rr in records]
    return {'rcode':dns.rcode.to_text(response.rcode()),'answer':section(response.answer),'authority':section(response.authority)}
def wait(predicate,timeout=15):
    deadline=time.monotonic()+timeout
    while time.monotonic()<deadline:
        try:
            if predicate():return
        except (OSError,dns.exception.Timeout):pass
        time.sleep(.05)
    raise AssertionError('DNS fixture condition did not become true')
def stop(process):
    if process.poll() is None:
        process.terminate()
        try:process.wait(timeout=5)
        except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5)

@contextmanager
def lab(records,soa_ttl=8,minimum=4,extra=''):
    with TemporaryDirectory(prefix='xbcatm-dns-') as directory,ExitStack() as stack:
        root=Path(directory);zone=root/'zone';conf=root/'named.conf';log=root/'named.log';serial=1
        def save(body):
            zone.write_text(f'$ORIGIN lab.test.\n$TTL 30\n@ {soa_ttl} IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 60 60 3600 {minimum})\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\n'+body+'\n')
            run('named-checkzone','lab.test',str(zone))
        save(records)
        conf.write_text(f'''options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}}; listen-on-v6 {{none;}};
recursion no; dnssec-validation no; notify no; querylog yes; max-cache-size 8m; pid-file "{root}/named.pid"; session-keyfile none;
{extra} }};
controls {{}};
zone "lab.test" {{ type primary; file "{zone}"; }};
''')
        run('named-checkconf',str(conf))
        output=stack.enter_context(log.open('w'))
        authority=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
        stack.callback(stop,authority)
        def ready():
            assert authority.poll() is None,log.read_text()
            response=query('lab.test.','SOA');return response.rcode()==0 and response.answer[0][0].serial==serial
        wait(ready)
        def update(body):
            nonlocal serial
            serial+=1;save(body);authority.send_signal(signal.SIGHUP);wait(ready)
        def cache(port=1054,upstream=1053,size=150,options=()):
            logfile=root/('cache-'+str(port)+'.log');stream=stack.enter_context(logfile.open('w'))
            command=['dnsmasq','--no-daemon','--conf-file=/dev/null','--no-resolv','--no-hosts','--bind-interfaces',
                '--listen-address=127.0.0.1','--port='+str(port),'--server=127.0.0.1#'+str(upstream),
                '--cache-size='+str(size),'--log-queries','--log-facility=-',*options]
            process=subprocess.Popen(command,stdout=stream,stderr=subprocess.STDOUT);stack.callback(stop,process)
            def cache_ready():
                assert process.poll() is None,logfile.read_text()
                return query('lab.test.','SOA',port).rcode()==0
            wait(cache_ready);return process,logfile
        def queries(name,kind='A'):
            return sum(bool(re.search(r'query: '+re.escape(name.rstrip('.'))+r' IN '+kind+r'\b',line)) for line in log.read_text().splitlines())
        yield root,update,cache,queries
with lab('exists 30 IN A 127.0.0.2',soa_ttl=8,minimum=4) as (_,update,cache,queries):
    cache();missing=query('new.lab.test.',port=1054);nodata=query('exists.lab.test.','AAAA',1054)
    assert missing.rcode()==dns.rcode.NXDOMAIN and not missing.answer
    assert nodata.rcode()==dns.rcode.NOERROR and not nodata.answer
    for answer in (missing,nodata):assert len(answer.authority)==1 and answer.authority[0].rdtype==dns.rdatatype.SOA and answer.authority[0].ttl==4
    wait(lambda: queries('new.lab.test')==1 and queries('exists.lab.test','AAAA')==1)
    again=query('new.lab.test.',port=1054);again_nodata=query('exists.lab.test.','AAAA',1054)
    assert again.rcode()==dns.rcode.NXDOMAIN and again_nodata.rcode()==0 and not again_nodata.answer
    assert queries('new.lab.test')==1 and queries('exists.lab.test','AAAA')==1
    positive=query('exists.lab.test.','A',1054);assert values(positive)==['127.0.0.2']
    update('exists 30 IN A 127.0.0.2\nexists 30 IN AAAA ::1\nnew 30 IN A 127.0.0.3')
    assert values(query('new.lab.test.'))==['127.0.0.3'] and values(query('exists.lab.test.','AAAA'),'AAAA')==['::1']
    stale_missing=query('new.lab.test.',port=1054);stale_nodata=query('exists.lab.test.','AAAA',1054)
    assert stale_missing.rcode()==dns.rcode.NXDOMAIN and stale_nodata.rcode()==0 and not stale_nodata.answer
    wait(lambda: values(query('new.lab.test.',port=1054))==['127.0.0.3'],timeout=7)
    wait(lambda: values(query('exists.lab.test.','AAAA',1054),'AAAA')==['::1'],timeout=7)
    result={'NXDOMAIN':show(missing),'NODATA':show(nodata),'repeat_upstream_queries':{'NXDOMAIN':1,'NODATA':1},'existing_A_unaffected':show(positive),
        'immediately_after_creation':{'NXDOMAIN':show(stale_missing),'NODATA':show(stale_nodata)},'new_records_observed_after_expiry':True}
with lab('exists 30 IN A 127.0.0.2',soa_ttl=2,minimum=9) as (_,update,cache,queries):
    smaller=query('new.lab.test.');assert smaller.rcode()==dns.rcode.NXDOMAIN and smaller.authority[0].ttl==2
    result['smaller_SOA_TTL']=show(smaller)
print(json.dumps(result,sort_keys=True))
"""
name='xbcatm-dns-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
    with TemporaryDirectory(prefix=name+'-') as directory:
        root=Path(directory);(root/'Dockerfile').write_text(recipe)
        built=docker('build','-t',name,str(root));assert built.returncode==0,built.stderr
        result=docker('run','--rm','-i','--name',name,'--network','none','--memory','384m','--pids-limit','128',name,
            '/usr/bin/python3','-',input=program)
        assert result.returncode==0,result.stderr
        print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
    docker('rm','-f',name);docker('image','rm',name)

An A query for new.lab.test returns NXDOMAIN. An AAAA query for exists.lab.test, which already has A 127.0.0.2, returns NOERROR with no answer data. Both negative responses contain an SOA with TTL 4. The program repeats both queries through the cache and confirms that each has generated only one upstream query, proving reuse rather than merely observing two identical responses.

Querying the existing name’s A record still succeeds. This shows that the cached absence of AAAA does not mean every type at that name is absent. The script next adds A for the new name and AAAA for the existing name, reloads the authoritative zone, and verifies the new records directly. The authority now returns 127.0.0.3 and ::1, respectively.

Immediately querying through dnsmasq still returns the earlier NXDOMAIN and NODATA results. After the negative entries expire, those queries return the new positive data. A second isolated zone reverses the limiting values: SOA TTL 2 and minimum 9 produce a negative response with TTL 2. Together the cases verify both sides of the minimum rule instead of only one convenient configuration.

Separate authoritative absence from resolution failure

SERVFAIL, timeout, and DNSSEC validation failure do not establish that a name is absent. They describe failure to obtain or accept an answer. They are not categorically uncacheable, however: RFC 9520 updates the requirements for caching DNS resolution failures, including validation failures. Its failure-cache lifetime rules differ from the SOA-derived lifetime used for the authoritative negative answers in this experiment.

Do not assume a resolver implements every current recommendation merely because it caches NXDOMAIN correctly. This fixture does not test failure caching or DNSSEC validation. Inspect the deployed resolver’s version, configuration, and diagnostics for those behaviors. Preserve response codes and extended error information when available so a cached validation problem is not mistaken for a recently deleted hostname.

Before publishing a new service, consider whether its names have already been queried while absent. Create required records early when the deployment permits, and allow any known negative cache window to drain before requiring successful discovery. An operator can clear a cache they control, but cannot normally purge every independent resolver used by clients. Verify both authoritative publication and the affected resolver’s current response.

Choose negative lifetime with recovery and repeated-miss load in mind. Repeated queries for one absent name can benefit from caching; a stream of unique names behaves differently and still consumes resources. Keep positive and negative query metrics distinguishable. The operational question is which fact was cached, under which name and type, for how long, and what evidence now shows that the client’s view has changed.

Sources