An IPv6 router does not fragment a packet while forwarding it toward its destination. If the packet is too large for the next link, the router normally discards it and sends ICMPv6 Packet Too Big feedback. The source can then adjust its packet size or, where appropriate, send fragments using the IPv6 Fragment header. Reassembly of those fragments takes place at the destination.

When it breaks

Assuming a router will split an oversized IPv6 packet hides a size-dependent failure. A small echo request can work while a larger one fails, even when both use the same addresses and route. The endpoint’s local interface may support the original size while an intermediate link does not. Check the entire path constraint rather than concluding that a configured 1500-byte interface guarantees that size end to end.

Missing Packet Too Big feedback makes it harder for classic discovery to learn that constraint. Fragment filtering or losing one fragment can also prevent successful reassembly. The ability to generate a Fragment header does not establish that every network policy will permit the resulting packets. Avoid treating source fragmentation as a universal substitute for correct path sizing and the required control traffic.

The reverse direction has its own path estimate. Learning the request path’s MTU does not automatically teach the responder how to size a large reply. A two-way echo test therefore needs both directions understood. TCP segmentation is also distinct from IP fragmentation: a TCP sender can form smaller segments after learning a path constraint without fragmenting each IP packet.

Check it quickly

Save this as check-ipv6-fragmentation.py and run python3 check-ipv6-fragmentation.py with Docker. The disposable environment uses Python 3.11.16, iproute2 6.1.0, iputils ping, and tcpdump 4.99.3, tested on Linux 6.12.76. NET_ADMIN and SYS_ADMIN support four nested namespaces. Two routers and two endpoints use newly created veth links without modifying host interfaces or routes:

from pathlib import Path
from tempfile import TemporaryDirectory
import subprocess,json,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends iproute2=6.1.0-3 iputils-ping=3:20221126-1+deb12u1 nftables=1.0.6-2+deb12u2 tcpdump=4.99.3-1 traceroute=1:2.1.2-1 procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*\nCMD ["python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,os,select,signal,subprocess,time
def run(*args,check=True,input=None,timeout=8):
    result=subprocess.run(args,input=input,capture_output=True,text=True,timeout=timeout)
    if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
    return result
def ns(name,*args,**kwargs):return run('ip','netns','exec',name,*args,**kwargs)
def ip(name,*args,**kwargs):return ns(name,'ip',*args,**kwargs)
def stop(process):
    if process.poll() is None:
        process.terminate()
        try:process.wait(timeout=2)
        except subprocess.TimeoutExpired:process.kill();process.wait(timeout=2)
def spawn(stack,command):
    process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
    stack.callback(stop,process);return process
def line(process,timeout=3):
    assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded'
    value=process.stdout.readline();assert value,process.stderr.read();return value
@contextmanager
def lab():
    with TemporaryDirectory(prefix='mtu-path-') as directory,ExitStack() as stack:
        root=Path(directory);proc=root/'proc';proc.mkdir();run('mount','-t','proc','proc',str(proc));stack.callback(run,'umount',str(proc))
        for name in ['client','r1','r2','target']:
            run('ip','netns','add',name);stack.callback(run,'ip','netns','delete',name,check=False);ip(name,'link','set','lo','up')
        def setting(name,key,value):
            assert name in ['client','r1','r2','target']
            path=proc/'sys'/key.replace('.','/')
            ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r});p.write_text({str(value)!r});assert p.read_text().strip()=={str(value)!r}')
        yield root,stack,setting
def topology(setting,ipv6=False):
    links=[('client','c0','r1','a0',1,2,1,1500),('r1','a1','r2','b0',2,1,2,1280),('r2','b1','target','t0',3,1,2,1500)]
    for left,left_if,right,right_if,network,left_host,right_host,mtu in links:
        run('ip','link','add',left_if,'type','veth','peer','name',right_if)
        for name,interface,host in [(left,left_if,left_host),(right,right_if,right_host)]:
            run('ip','link','set',interface,'netns',name);ip(name,'link','set',interface,'mtu',str(mtu))
            address=f'2001:db8:{network}::{host}/64' if ipv6 else f'10.210.{network}.{host}/30'
            ip(name,'addr','add',address,'dev',interface,*(['nodad'] if ipv6 else []));ip(name,'link','set',interface,'up')
    family='-6' if ipv6 else '-4'
    for name in ['r1','r2']:setting(name,'net.ipv6.conf.all.forwarding' if ipv6 else 'net.ipv4.ip_forward',1)
    routes=[('client','default','2001:db8:1::1' if ipv6 else '10.210.1.1'),('target','default','2001:db8:3::1' if ipv6 else '10.210.3.1'),
        ('r1','2001:db8:3::/64' if ipv6 else '10.210.3.0/30','2001:db8:2::2' if ipv6 else '10.210.2.2'),
        ('r2','2001:db8:1::/64' if ipv6 else '10.210.1.0/30','2001:db8:2::1' if ipv6 else '10.210.2.1')]
    for name,destination,gateway in routes:ip(name,family,'route','add',destination,'via',gateway)
    if ipv6:
        deadline=time.monotonic()+4
        while any('tentative' in ip(name,'-6','address','show').stdout for name in ['client','r1','r2','target']):
            assert time.monotonic()<deadline,'IPv6 link-local DAD did not finish';time.sleep(.02)
def nft(name,script):return ns(name,'nft','-f','-',input=script)
def counters(name):
    result={}
    for item in json.loads(ns(name,'nft','-j','list','ruleset').stdout)['nftables']:
        rule=item.get('rule',{})
        if 'comment' in rule:
            for expression in rule['expr']:
                if 'counter' in expression:result[rule['comment']]=expression['counter']['packets']
    return result
def capture(stack,root,name,interface,expression,label):
    path=root/(label+'.pcap')
    process=subprocess.Popen(['ip','netns','exec',name,'tcpdump','--immediate-mode','-Z','root','-U','-n','-i',interface,'-w',str(path),expression],stdout=subprocess.DEVNULL,stderr=subprocess.PIPE,text=True)
    stack.callback(stop,process);deadline=time.monotonic()+3
    while time.monotonic()<deadline:
        assert process.poll() is None,'capture exited'
        if select.select([process.stderr],[],[],.1)[0] and 'listening on' in process.stderr.readline():return process,path
    raise AssertionError('capture did not become ready')
def captured(process,path):
    process.send_signal(signal.SIGINT);_,diagnostic=process.communicate(timeout=3);assert process.returncode==0,diagnostic
    return run('tcpdump','-nn','-vv','-r',str(path)).stdout

with lab() as (root,stack,setting):
    topology(setting,ipv6=True);target='2001:db8:3::2'
    small=ns('client','ping','-6','-n','-c','1','-W','1','-s','32',target);assert small.returncode==0
    tap,path=capture(stack,root,'client','c0','ip6','too-big')
    large=ns('client','ping','-6','-n','-M','do','-s','1400','-c','1','-W','1',target,check=False)
    feedback=captured(tap,path);assert large.returncode!=0 and 'too big' in feedback.lower(),(large.stdout,large.stderr,feedback)
    route=ip('client','-6','route','get',target).stdout;assert 'mtu 1280' in route,route
    reverse=ns('target','ping','-6','-n','-M','do','-s','1400','-c','1','-W','1','2001:db8:1::2',check=False)
    reverse_route=ip('target','-6','route','get','2001:db8:1::2').stdout
    assert reverse.returncode!=0 and 'mtu 1280' in reverse_route,(reverse.stdout,reverse.stderr,reverse_route)
    tap,path=capture(stack,root,'client','c0','ip6 and src 2001:db8:1::2','source-fragments')
    fragmented=ns('client','ping','-6','-n','-M','dont','-s','2000','-c','1','-W','2',target,check=False)
    trace=captured(tap,path);assert fragmented.returncode==0,(fragmented.stdout,fragmented.stderr,trace)
    assert 'frag (' in trace and trace.count('frag (')>=2,trace
    refused=ns('client','ping','-6','-n','-M','do','-s','2000','-c','1','-W','1',target,check=False)
    assert refused.returncode!=0 and 'too long' in (refused.stdout+refused.stderr).lower(),(refused.stdout,refused.stderr)
    print(json.dumps({'small_echo':small.stdout,'oversize_before_learning':{'stdout':large.stdout,'stderr':large.stderr,'returncode':large.returncode},
        'packet_too_big_capture':feedback,'learned_route':route,'reverse_path_learning':reverse.stdout+reverse.stderr,'reverse_route':reverse_route,'source_fragmented_echo':fragmented.stdout,
        'source_side_fragment_capture':trace,'dont_fragment_after_learning':{'returncode':refused.returncode,'stdout':refused.stdout,'stderr':refused.stderr}}))
"""
name='xbcatm-mtupath-'+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)
  build=docker('build','-t',name,str(root));assert build.returncode==0,build.stderr
  result=docker('run','--rm','-i','--name',name,'--network','none','--cap-add','NET_ADMIN','--cap-add','SYS_ADMIN','--memory','256m','--pids-limit','128',name,'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 endpoint links have MTU 1500 and the intermediate router link has MTU 1280. Static global addresses use nodad, while setup explicitly waits for automatically generated link-local addresses to leave the tentative state. That readiness check avoids mistaking initial address setup delays for a forwarding or MTU failure. The experiment does not claim to test the Duplicate Address Detection algorithm itself.

A small echo exchange succeeds first. An echo payload of 1400 bytes, sent with the do policy, then creates a 1448-byte IPv6 packet after its base and ICMPv6 headers are included. It fits the source interface but not the middle link. The capture shows the original request and a router Packet Too Big message reporting MTU 1280; the client route lookup records that learned value.

The target performs the corresponding oversized probe toward the client and learns MTU 1280 for the reverse path. This explicit step prepares its large echo reply rather than assuming that one endpoint’s cache covers both directions. Both failed discovery probes remain in the output, with their router feedback and route state, so the setup conditions behind later success are visible.

The client next sends a 2000-byte echo payload with the dont policy, allowing source fragmentation under the learned limit. A capture on the client interface, restricted to the client’s source address, shows two fragments with the same identification before either router processes them. Their displayed data offsets are zero and 1232 bytes, and their fragment data lengths sum to 2008, including the ICMPv6 header.

That echo succeeds after destination reassembly and a correctly sized return exchange. Repeating the same payload with do instead reports a local message-too-long error and MTU 1280. The results distinguish a router reporting an unknown bottleneck, source-generated fragments after discovery, and a local refusal once the smaller path limit is known. They are different failure and delivery stages.

Diagnose size and direction before recommending fragmentation

Include all relevant headers when calculating packet size. A fragment adds its own header, and the IPv6 Fragment Offset field uses eight-byte units even though diagnostic tools may display byte offsets. Use consistent units when reconstructing ranges. The script’s two source fragments provide concrete evidence; the absence of a fragment from an unrelated capture would require more context to interpret.

An IPv6 router can still act as the source of its own packets, which is separate from fragmenting transit traffic. Keep that role distinction when reading captures from routers or tunnel endpoints. Also distinguish the path’s minimum link MTU from a particular application’s chosen payload size: they are related by protocol overhead and the sender’s packetization policy.

Prefer application and transport behavior that fits the known path where possible, and verify any required fragment handling with actual traffic. This exercise proves source placement, feedback, route learning, and a successful fragmented echo. It does not validate arbitrary extension-header chains, fragment-filtering policies, or every transport’s behavior under missing control messages.

Sources

  • RFC 8200 IPv6, source fragmentation, Fragment headers and destination reassembly, checked 2026-09-12.
  • RFC 8201 IPv6 Path MTU Discovery, Packet Too Big feedback and directional path estimates, checked 2026-09-12.
  • Linux ping(8), payload sizing and do/dont path-MTU policies, checked 2026-09-12.