DNS supports both UDP and TCP. A server sets the TC flag when a response is truncated for the available message space. A client receiving that flag must handle the incomplete result, commonly by retrying over TCP. This is about the DNS response limit in use, not proof that the physical network cannot carry any larger UDP datagram.
When it breaks
A network that permits UDP port 53 but blocks TCP port 53 can pass small lookups and fail larger ones. DNSSEC data and larger record sets make the difference visible, but TCP is not reserved for those cases. General-purpose DNS implementations need TCP support, and clients may choose it directly. A successful small UDP query therefore does not establish that the DNS service’s complete transport path works.
A truncated answer can still contain records. Ignoring TC and using those records as the complete set can omit valid destinations or other required data. NOERROR does not cancel the truncation flag. Capture the flags and complete question alongside answer counts, especially when an application appears to see fewer records than a direct authoritative query through a different transport.
TCP also does not remove all DNS message-size limits. Each DNS message on a TCP stream is preceded by a two-byte length, which limits that message to 65,535 bytes. A zone transfer can use multiple DNS messages. Stream reads may split either the length prefix or the message, so code must read the required number of bytes rather than assuming one recv call returns a whole answer.
Check it quickly
Save this as check-dns-tcp.py and run python3 check-dns-tcp.py with Docker. The pinned BIND 9.18.49 environment serves a four-record TXT set inside an isolated container. The initial query explicitly disables EDNS to exercise the traditional 512-byte UDP limit. Later stages use a real TCP connection and a deliberately UDP-only relay:
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,socket,struct,threading
texts=[f'{i}-'+('x'*200) for i in range(4)]
records='small 30 IN A 127.0.0.2\n'+'\n'.join('large 30 IN TXT "'+s+'"' for s in texts)
def exact(stream,size):
data=b''
while len(data)<size:
part=stream.recv(size-len(data));assert part,'unexpected TCP EOF';data+=part
return data
def description(response):return {'wire_bytes':len(response.to_wire()),'TC':bool(response.flags&dns.flags.TC),'answer_records':sum(len(rr) for rr in response.answer)}
with lab(records) as (_,update,cache,queries):
q=dns.message.make_query('large.lab.test.','TXT',use_edns=False)
udp=dns.query.udp(q,'127.0.0.1',port=1053,timeout=2)
assert udp.flags&dns.flags.TC and len(udp.to_wire())<=512
tcp=dns.query.tcp(q,'127.0.0.1',port=1053,timeout=2)
assert not tcp.flags&dns.flags.TC and set(values(tcp,'TXT'))=={'"'+s+'"' for s in texts}
assert len(tcp.to_wire())>512
with socket.create_connection(('127.0.0.1',1053),timeout=2) as stream:
lengths=[]
for name,kind in [('large.lab.test.','TXT'),('small.lab.test.','A')]:
request=dns.message.make_query(name,kind);wire=request.to_wire();stream.sendall(struct.pack('!H',len(wire))+wire)
size=struct.unpack('!H',exact(stream,2))[0];reply=dns.message.from_wire(exact(stream,size));assert reply.id==request.id and reply.rcode()==0
assert values(reply,kind);lengths.append(size)
with socket.socket() as reservation,socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as relay:
reservation.bind(('127.0.0.1',0));port=reservation.getsockname()[1];relay.bind(('127.0.0.1',port));relay.settimeout(3)
errors=[]
def one_datagram():
try:
data,peer=relay.recvfrom(4096);answer=dns.query.udp(dns.message.from_wire(data),'127.0.0.1',port=1053,timeout=2);relay.sendto(answer.to_wire(),peer)
except Exception as error:errors.append(type(error).__name__)
thread=threading.Thread(target=one_datagram);thread.start()
limited=dns.query.udp(q,'127.0.0.1',port=port,timeout=2);assert limited.flags&dns.flags.TC
thread.join(timeout=4);assert not thread.is_alive() and not errors
try:dns.query.tcp(q,'127.0.0.1',port=port,timeout=1);raise AssertionError('UDP-only endpoint unexpectedly accepted TCP')
except OSError as error:failure=type(error).__name__
print(json.dumps({'UDP':description(udp),'TCP':description(tcp),'two_messages_one_connection_lengths':lengths,
'UDP_only_path':{'TC_received':True,'TCP_error':failure}},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)
The recorded UDP response is 462 bytes, has TC set, and contains only two of the four TXT records. The TCP response is 892 bytes, has TC clear, and contains all four expected values. The script compares the complete record contents, rather than accepting a larger byte count as proof that the missing data was recovered correctly.
The program next opens one TCP connection and sends two DNS queries through it, first for the large TXT answer and then for a small A answer. It reads each two-byte length prefix and the corresponding message body, obtaining message lengths 892 and 48 in this run. It also checks response IDs. The same stream serves both exchanges, demonstrating that a fresh handshake is not required for every query.
A separate endpoint forwards one UDP question to the real authority and returns its truncated response. On that same endpoint number, a reserved but non-listening TCP socket prevents a TCP listener from existing. The UDP query succeeds with TC, while the TCP attempt fails with ConnectionRefusedError. This reproduces the transport mismatch without changing firewall rules or relying on an unrelated public resolver.
Diagnose truncation and connection failure separately
Check the response flags, advertised EDNS capacity if present, TCP connection result, and final DNS contents. A TC response followed by a refused connection has a different cause from an unanswered UDP packet or an authoritative NXDOMAIN. The relay is intentionally limited to one exchange; it is a controlled failure fixture, not a general DNS proxy implementation intended for deployment.
Permit the required TCP path through the client, resolver, server listener, and intervening access rules. Keep connection deadlines and resource limits appropriate to the resolver workload. Reuse can reduce repeated setup work, but idle connections, connection limits, and server behavior still matter. Do not convert the extra handshake observed for one new connection into a fixed latency surcharge for every TCP lookup.
EDNS can allow a larger UDP response and avoid truncation in many cases, but it does not eliminate the need for TCP. Larger datagrams can encounter path-MTU and fragmentation problems before the client ever receives a useful TC signal. Test the configured UDP size alongside a complete TCP exchange, and avoid treating an unusually large advertised payload as a substitute for a working second transport.
Finally, TCP provides a reliable byte stream, not DNS confidentiality or authenticated record data. Plain DNS over TCP remains plaintext. TLS-based DNS transport and DNSSEC address separate security properties. Keep those claims separate from this experiment, whose evidence establishes truncation handling, complete record recovery, stream framing, connection reuse, and a concrete failure when only UDP is available.
Sources
- RFC 7766 DNS over TCP, TCP support, connection reuse, and DNS transport requirements, checked 2026-09-12.
- RFC 1035 domain names, TC, traditional UDP size, and two-byte TCP framing, checked 2026-09-12.
- RFC 5936 AXFR, zone transfers spanning multiple DNS messages, checked 2026-09-12.