EDNS extends DNS through an OPT pseudo-record. Among other information, a request can advertise the UDP payload size its sender is prepared to receive. This allows answers beyond the traditional 512-byte DNS limit. It is an advertisement within a DNS exchange, not a network path-MTU discovery protocol or a promise that every intervening device will deliver a datagram of that size.

When it breaks

A client advertising 4096 bytes does not compel the server to send a 4096-byte response. The server can apply a smaller outgoing limit. If the required answer does not fit, it can return TC and the client still needs a working TCP path. Increasing the advertised number cannot fix a server policy that deliberately uses a smaller response budget.

Conversely, a response that fits the advertised capacity can still be lost along the path. Fragmentation, tunnels, packet filters, and local network constraints are separate from what the DNS participants advertise. If the client receives no response, it may never see a TC signal. Treat payload settings as an interoperability choice to verify, not a universal performance knob whose maximum value is always best.

EDNS also carries information that must not be mistaken for ordinary cached DNS data. OPT is a pseudo-record describing the exchange. Its fields encode such things as payload capacity, version, extended response code, and flags; its TTL-shaped field is not a normal record lifetime. The DNSSEC OK flag, DO, asks for relevant DNSSEC records but does not prove that anyone validated the answer.

Check it quickly

Save this as check-edns.py and run python3 check-edns.py with Docker. The pinned BIND 9.18.49 authority runs on isolated loopback networking. Its edns-udp-size and max-udp-size are explicitly set to 1232. The zone contains a medium TXT set with four records and a larger set with eight, allowing capacity and truncation to be observed independently:

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
import dns.flags
records='\n'.join(f'{name} 30 IN TXT "{i}-'+('x'*200)+'"' for name,count in [('medium',4),('large',8)] for i in range(count))
def ask(name,payload=None,version=0,flags=0):
    q=dns.message.make_query(name+'.lab.test.','TXT',use_edns=False)
    if payload is not None:q.use_edns(edns=version,ednsflags=flags,payload=payload)
    return dns.query.udp(q,'127.0.0.1',port=1053,timeout=2)
def description(response):return {'wire_bytes':len(response.to_wire()),'TC':bool(response.flags&dns.flags.TC),
    'rcode':dns.rcode.to_text(response.rcode()),'EDNS_version':response.edns,'advertised_payload':response.payload,
    'DO':bool(response.ednsflags&dns.flags.DO),'AD':bool(response.flags&dns.flags.AD),'answer_records':sum(len(rr) for rr in response.answer)}
with lab(records,extra='edns-udp-size 1232; max-udp-size 1232;') as (_,update,cache,queries):
    classic=ask('medium');extended=ask('medium',1232);capped=ask('large',4096);do=ask('medium',1232,flags=dns.flags.DO);version=ask('medium',1232,version=1)
    assert classic.flags&dns.flags.TC and len(classic.to_wire())<=512 and classic.edns==-1
    assert not extended.flags&dns.flags.TC and 512<len(extended.to_wire())<=1232 and len(values(extended,'TXT'))==4
    assert extended.edns==0 and extended.opt is not None
    assert capped.flags&dns.flags.TC and len(capped.to_wire())<=1232
    assert do.ednsflags&dns.flags.DO and not do.flags&dns.flags.AD
    assert not any(rr.rdtype==dns.rdatatype.RRSIG for rr in do.answer)
    assert version.rcode()==dns.rcode.BADVERS and version.edns==0
    full=dns.query.tcp(dns.message.make_query('large.lab.test.','TXT'),'127.0.0.1',port=1053,timeout=2)
    assert len(values(full,'TXT'))==8 and len(full.to_wire())>1232
    print(json.dumps({'without_EDNS':description(classic),'EDNS_1232':description(extended),'client_4096_server_1232':description(capped),
        'DO_on_unsigned_zone':description(do),'unsupported_version':description(version),'TCP_full':description(full)},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)

Without EDNS, the medium query returns a 463-byte truncated response containing two TXT records. With an advertised payload of 1232, it returns all four records in 904 bytes with TC clear. The script checks membership count, wire length, and EDNS presence. This shows a concrete answer moving past the traditional limit while staying inside the selected UDP response budget.

For the larger set, the client advertises 4096 but the server remains capped at 1232. The recorded response is only 43 bytes with TC set and no answer records. A subsequent TCP query retrieves all eight records in 1752 bytes. A truncated reply need not fill the entire allowed buffer; record grouping and response construction can produce a much smaller signal that more data is needed.

The program also sets DO on a query to this unsigned zone. The response preserves DO, but has no RRSIG and no authenticated-data AD flag. That combination is expected: requesting DNSSEC-related data cannot manufacture signatures or a chain of trust. The example records the flags separately rather than labeling a DO-bearing exchange as a successful DNSSEC validation.

Finally, a query advertises EDNS version 1. This server returns BADVERS and indicates supported version 0. The library decodes the extended response code from the complete message. This negative test distinguishes unsupported EDNS version from an absent name or an ordinary successful empty answer, and demonstrates why diagnostics must retain OPT information when interpreting extension-related failures.

Measure the configured exchange rather than assuming a default

Record the actual client advertisement, server configuration, response size, flags, and transport. Different software versions and operators choose different payload values; neither 1232 nor 4096 is a universal default guaranteed by the standard. The fixture’s 1232 value is an explicit test configuration. Its loopback results do not measure fragmentation, a remote path’s MTU, or public resolver performance.

When diagnosing large-answer failures, compare a small query, a larger EDNS query, and a full TCP query through the same intended endpoint. Keep error categories separate: TC requests completion through another exchange, BADVERS reports extension-version disagreement, and a timeout does not identify which network component lost a packet. Preserve enough of the reply to avoid collapsing those distinct failures into “DNS is down.”

Use the deployment’s supported sizing and retry policy, then validate from the networks that matter to clients. Retain TCP access even when most observed answers fit in UDP. EDNS improves extensibility and can reduce some truncation retries, but complete delivery still depends on transport behavior, server limits, and client handling of the answer actually received.

Sources