A sparse regular file can have a large logical length while allocating storage for only part of its address range. Reads from holes within that length return zero bytes. File length, byte content, and filesystem allocation are therefore different measurements. Understanding their relationship matters when budgeting virtual disks, copying backups, or deciding whether a zero-filled region actually reserves space for future writes.
When it breaks
Seeking beyond the end of a file changes the descriptor’s offset, not the file’s length. A later write or an explicit extension can create the intervening zero-reading range. Calling seek alone and reporting that a large sparse file now exists is incorrect. Likewise, a large logical size does not mean the filesystem has reserved enough blocks to fill that file later.
A copy that reads every byte and writes it normally can turn holes into allocated zero-filled ranges. The resulting checksum can match perfectly while its allocation is much larger. Transfer and restore workflows that budget only the source’s current allocated space can therefore run out of capacity despite preserving the logical content correctly.
Hole punching is an intentional data operation. Punching an allocated range makes the corresponding bytes read as zeros while preserving length when requested with KEEP_SIZE. It is not a harmless command to tidy away arbitrary file regions. Preallocation solves a different problem: it reserves filesystem allocation, even though an unwritten allocated range can also read as zeros.
Check it quickly
Save the script as sparse.py and run python3 sparse.py using Python 3.11 or later on a disposable Linux system with an ext4 temporary directory. The recorded run used Linux 6.12.76 and Python 3.11.2. It creates three temporary files and uses about 32 MiB of fully allocated data plus the small sparse allocation:
from pathlib import Path
from tempfile import TemporaryDirectory
import ctypes,hashlib,os
size=16*1024*1024;first=4*1024*1024;second=12*1024*1024
libc=ctypes.CDLL(None,use_errno=True);libc.fallocate.argtypes=[ctypes.c_int,ctypes.c_int,ctypes.c_longlong,ctypes.c_longlong];libc.fallocate.restype=ctypes.c_int
def digest(path):
with path.open('rb') as handle:return hashlib.file_digest(handle,'sha256').hexdigest()
with TemporaryDirectory(prefix='xbcatm-sparse-') as directory:
root=Path(directory);path=root/'sparse';fd=os.open(path,os.O_RDWR|os.O_CREAT|os.O_EXCL,0o600)
try:
os.lseek(fd,size,os.SEEK_SET);assert os.fstat(fd).st_size==0
os.ftruncate(fd,size);assert os.fstat(fd).st_size==size and os.fstat(fd).st_blocks==0
assert os.pread(fd,16,0)==b'\0'*16
os.pwrite(fd,b'A',first);os.pwrite(fd,b'B',second);os.fsync(fd)
allocated=os.fstat(fd).st_blocks*512;assert allocated<size
data_start=os.lseek(fd,0,os.SEEK_DATA);hole_start=os.lseek(fd,data_start,os.SEEK_HOLE)
assert data_start==first and first<hole_start<=second
print('seek alone leaves size zero; truncate creates zero-reading holes; sparse size',size,'allocated',allocated)
print('first SEEK_DATA',data_start,'following SEEK_HOLE',hole_start)
dense=root/'dense'
with path.open('rb') as source,dense.open('wb') as target:
while chunk:=source.read(65536):target.write(chunk)
target.flush();os.fsync(target.fileno())
assert digest(path)==digest(dense)
assert dense.stat().st_blocks*512>=size
print('explicitly writing all copied bytes preserves content but allocates the zero-filled ranges')
result=libc.fallocate(fd,0x01|0x02,first,4096) # KEEP_SIZE | PUNCH_HOLE
assert result==0,os.strerror(ctypes.get_errno())
os.fsync(fd)
assert os.fstat(fd).st_size==size and os.pread(fd,1,first)==b'\0'
assert os.pread(fd,1,second)==b'B' and os.fstat(fd).st_blocks*512<allocated
print('punching the aligned data range releases its blocks and makes it read zero without changing file length')
reserved=root/'reserved'
with reserved.open('w+b') as handle:
os.posix_fallocate(handle.fileno(),0,size);os.fsync(handle.fileno())
info=os.fstat(handle.fileno());assert info.st_size==size and info.st_blocks*512>=size
assert handle.read(16)==b'\0'*16
print('preallocation reserves blocks even while the newly allocated range reads as zeros')
finally:os.close(fd)
The fixture first seeks to sixteen MiB in an empty file and verifies that its size remains zero. Truncating to that length then creates a file with sixteen MiB of logical content and no allocated data blocks in this ext4 case. Reading its beginning returns zeros, establishing the difference between moving an offset and extending the file.
Writing one byte at four MiB and another at twelve MiB allocates two small regions. After synchronization, the recorded file length is 16777216 bytes and its reported allocation is 8192 bytes. Linux’s st_blocks count is converted using 512-byte units; treating it as a count of the filesystem’s larger block size would miscalculate the result.
The first SEEK_DATA result is 4194304, and the following SEEK_HOLE result is 4198400 in the recorded run. These describe the ext4 region around the first written byte, not only that byte’s nonzero position. A data extent may contain zeros too, so extent reporting is not a search for nonzero content.
The script explicitly copies all logical bytes through ordinary writes into a second file. Its digest matches the sparse source, but its allocated space covers the full logical length. This controlled dense copy demonstrates the expansion risk without claiming that every modern copy tool or filesystem necessarily materializes holes in the same way.
Next, an aligned punch removes the first allocated data region. The original A byte now reads as zero, the distant B byte remains, length stays unchanged, and allocated blocks decrease. Finally, preallocating the third file reserves space across sixteen MiB while its initial bytes read as zeros. Zero content alone cannot distinguish a hole from reserved allocation.
Budget and preserve the intended representation
Check both logical size and allocated blocks after the actual archive, transfer, and restore path. A sparse-aware option may preserve holes, while compression, reflinks, and filesystem-specific copying can produce other storage relationships. Per-file allocation also should not automatically be interpreted as exclusive physical-device usage when the filesystem shares or compresses extents.
Use the target filesystem’s documented preallocation contract when future allocation must be reserved. A merely truncated virtual image can grow its storage consumption later and fail when the shared pool is full. Capacity planning needs the expected populated size and competing allocations, not just an attractive small allocation measured at creation time.
Treat SEEK_DATA and SEEK_HOLE as filesystem-provided extent information. Implementations are allowed to report holes conservatively, including representing a broader range as data. Verify support and behavior before depending on exact offsets. When punching or copying ranges, preserve the application’s intended bytes first; space savings are useful only when the resulting data and allocation match the operation’s actual purpose.
Sources
- lseek(2), seek position, SEEK_DATA/SEEK_HOLE, and filesystem reporting limits, checked 2026-09-12.
- truncate(2), extending logical length with zero-reading regions, checked 2026-09-12.
- fallocate(2), preallocation and hole punching without changing logical size, checked 2026-09-12.
- Linux 6.12 fallocate UAPI, KEEP_SIZE and PUNCH_HOLE flags used by the fixture, checked 2026-09-12.