An AAAA resource record contains one 128-bit IPv6 address. An A record contains an IPv4 address. A name may have either type, both types, or several addresses within each type. These are separate RRsets, so A and AAAA can have different TTLs. Publishing AAAA makes an address discoverable through DNS; it does not create an interface, route, listener, or working application.
When it breaks
A successful AAAA lookup can lead to a failed connection. The published address may be obsolete, the service may listen only on IPv4, or the path may lack usable IPv6 routing. Firewall rules and TLS configuration can also differ between families. An IPv4-only availability check can miss the problem while dual-stack users experience delays or failures through the advertised IPv6 path.
Happy Eyeballs reduces some dual-stack connection delays by coordinating address discovery and connection attempts. It is a client algorithm, not a DNS guarantee that every client implements. Fallback behavior, deadlines, and application protocols still matter. Publishing a broken AAAA is therefore not automatically harmless merely because a particular browser quickly succeeds through an A record during one test.
Missing AAAA also needs precise language. An authoritative A-only name can produce a successful DNS response with no AAAA data, commonly called NODATA. That differs from NXDOMAIN, which reports a nonexistent name. An A-only service is not categorically unreachable from every IPv6-only network: DNS64 can synthesize AAAA answers for use with NAT64 translation when that infrastructure and its requirements are present.
Check it quickly
Save the script as check-aaaa.py and run python3 check-aaaa.py with Docker available. The pinned environment uses BIND 9.18.49 and dnspython 2.3.0. Its isolated network contains working IPv6 loopback but no external route. The program starts a real HTTP listener on ::1 and performs both DNS queries and a separate IPv6 connection:
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
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import http.client,socket,threading
records='dual 17 IN A 127.0.0.1\ndual 29 IN AAAA ::1\nv4-only 17 IN A 127.0.0.1\nbroken 29 IN AAAA 2001:db8::1234'
class Handler(BaseHTTPRequestHandler):
def log_message(self,*args):pass
def do_GET(self):
body=b'IPv6 response';self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body)
class V6Server(ThreadingHTTPServer):address_family=socket.AF_INET6
with lab(records) as (_,update,cache,queries),V6Server(('::1',0),Handler) as server:
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
try:
a=query('dual.lab.test.','A');aaaa=query('dual.lab.test.','AAAA')
assert values(a)==['127.0.0.1'] and values(aaaa,'AAAA')==['::1']
assert a.answer[0].ttl==17 and aaaa.answer[0].ttl==29
address=values(aaaa,'AAAA')[0];assert len(socket.inet_pton(socket.AF_INET6,address))==16
client=http.client.HTTPConnection(address,server.server_port,timeout=2)
client.request('GET','/');reply=client.getresponse();body=reply.read();client.close()
assert reply.status==200 and body==b'IPv6 response'
only_a=query('v4-only.lab.test.');assert values(only_a)==['127.0.0.1']
missing=query('v4-only.lab.test.','AAAA');assert missing.rcode()==0 and missing.answer==[]
assert len(missing.authority)==1 and missing.authority[0].rdtype==dns.rdatatype.SOA
broken=query('broken.lab.test.','AAAA');assert values(broken,'AAAA')==['2001:db8::1234']
unreachable=socket.socket(socket.AF_INET6);unreachable.settimeout(.3)
try:unreachable.connect(('2001:db8::1234',server.server_port));raise AssertionError('documentation address unexpectedly reachable')
except OSError as error:failure=type(error).__name__
finally:unreachable.close()
print(json.dumps({'A':show(a),'AAAA_over_IPv4_DNS':show(aaaa),'IPv6_HTTP_status':reply.status,
'AAAA_wire_address_bytes':16,'A_only_AAAA_query':show(missing),'published_but_unreachable':{'answer':show(broken),'connect_error':failure}},sort_keys=True))
finally:server.shutdown();thread.join(timeout=2)
"""
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 name dual.lab.test returns A 127.0.0.1 with TTL 17 and AAAA ::1 with TTL 29. Both DNS queries travel to an IPv4 DNS socket at 127.0.0.1. Receiving AAAA over that socket demonstrates that the address family described by the record is independent of the transport carrying the DNS question and answer.
The program converts the AAAA text into its packed form and checks that it occupies 16 bytes. It then uses the returned IPv6 address to request the local HTTP service. The recorded response is status 200 with the expected body. This verifies a usable IPv6 loopback connection for the selected address, rather than treating the existence of an AAAA record as the application test.
Querying AAAA for v4-only.lab.test returns NOERROR, no answer RRset, and an SOA in the authority section. The name still has its A record. Meanwhile, broken.lab.test successfully returns the documentation address 2001:db8::1234; connecting to it fails in this isolated environment. The result deliberately separates positive DNS data from transport success without sending test traffic to a real external host.
Check each advertised family through the service
Before publishing an IPv6 address, establish the listener, route, access rules, and application behavior. Test the hostname with its normal identity and test the individual advertised endpoints. A raw-address HTTP success does not validate hostname-based TLS or virtual-host routing. The example verifies local HTTP only; it neither demonstrates public IPv6 connectivity nor evaluates a client’s Happy Eyeballs implementation.
Keep both RRsets in monitoring and deployment records. Removing AAAA stops new authoritative answers but does not invalidate addresses already cached by clients. Existing connections have their own lifetimes. During a move, retain the old IPv6 service for the intended overlap and compare authoritative data with the recursive resolver used by affected clients before attributing every old connection to a stale DNS answer.
Finally, AAAA is forward address data, despite occasional descriptions calling it an IPv6 pointer. Reverse lookup uses PTR records under ip6.arpa, with reversed hexadecimal nibbles derived from the address. Publishing forward AAAA does not automatically publish that reverse record. Treat forward lookup, reverse naming, address selection, and successful application traffic as related checks with distinct configurations and observable outcomes.
Sources
- RFC 3596 DNS extensions for IPv6, AAAA address data, transport independence, and ip6.arpa reverse names, checked 2026-09-12.
- RFC 6147 DNS64, synthesized AAAA records for access to IPv4 servers, checked 2026-09-12.
- RFC 8305 Happy Eyeballs version 2, client address selection and connection attempts, checked 2026-09-12.