splice() can transfer bytes between two file descriptors without first copying the payload into a userspace buffer, but the interface requires at least one endpoint to be a pipe. That requirement makes the pipe more than an incidental transport. It is the kernel-visible buffer boundary around which the operation’s offset, blocking, capacity, and partial-progress semantics are defined.

This differs from a conventional read() followed by write(). In that sequence, userspace owns an intermediate byte array and can inspect or modify it. With splice(), the payload can remain in kernel-managed storage while the process coordinates movement between endpoints.

The pipe is part of the interface contract

The Linux splice() system call accepts an input descriptor, an output descriptor, a byte limit, optional offsets, and flags. At least one descriptor must refer to a pipe. If neither does, the call fails with EINVAL.

A file-to-socket relay therefore commonly has two transfer steps rather than one:

ssize_t n = splice(file_fd, NULL, pipefd[1], NULL, limit, 0);
if (n > 0)
    splice(pipefd[0], NULL, socket_fd, NULL, (size_t)n, 0);

The pipe separates ingress from egress. Bytes accepted by the first call occupy pipe capacity until the second call consumes them. A successful first transfer does not imply that the destination socket has accepted the same bytes.

That separation is observable under backpressure. If the socket cannot accept more data, the pipe can retain pending bytes. Once the pipe lacks free capacity, additional input-side transfer must wait, return partial progress, or fail with EAGAIN under applicable nonblocking conditions.

Avoiding a userspace copy does not erase buffering

The defining property is that splice() transfers data without copying the payload between kernel address space and user address space. It does not mean that no buffering exists or that every endpoint can hand the same physical pages directly to another endpoint.

Linux pipe buffers internally describe kernel-managed data. Depending on the source, destination, filesystem, and kernel path, data can be represented through references to pages rather than copied into a userspace array. The interface contract, however, is about the transfer operation, not a universal promise of page movement.

SPLICE_F_MOVE illustrates that boundary. The flag is specified as a request to move pages instead of copying when possible, but it is only a hint. Current documented Linux behavior does not make page movement a caller-visible guarantee. Code that depends on splice() remains correct even when the kernel performs an internal copy.

This separates two claims that are often merged. The call avoids the ordinary kernel-to-userspace-to-kernel payload path. It does not establish that every byte reaches its destination with zero internal copying.

Offsets belong to non-pipe endpoints

A pipe has stream semantics rather than a seekable file position, so its corresponding offset pointer must be NULL. Supplying an offset for a pipe produces an error.

For a non-pipe input descriptor, a NULL offset means that splice() uses and advances that descriptor’s file offset. A non-NULL pointer selects an explicit starting offset; the pointed value advances by the amount transferred while the descriptor’s own file offset remains unchanged. The output side follows the analogous rule when it is not a pipe.

That distinction matters when multiple operations share an open file description. Using the descriptor’s current offset participates in the normal shared-offset semantics of that open file description. Using an explicit offset pointer gives the transfer its own position value without changing the descriptor offset.

The offset arguments therefore control position accounting, not pipe placement. Data entering a pipe is ordered by the pipe’s stream state, while file positioning remains attached to the non-pipe endpoint.

A positive return value is partial progress

The requested byte count is an upper bound. A successful call can transfer fewer bytes and return that smaller count.

Any relay built around splice() must retain the same partial-progress discipline required by other I/O interfaces. If 64 KiB is requested and 12 KiB is returned, the remaining 52 KiB has not been transferred by that call. On a two-stage relay, the second stage must also account for partial consumption from the pipe.

This creates two independent progress counters: bytes admitted into the pipe and bytes drained from it. Treating them as one counter can discard pending pipe data or read new input before prior data has been fully emitted.

A return value of zero has endpoint-specific meaning. For a non-pipe input such as a regular file, it can represent end of input. When the input is a pipe, zero indicates that no data remains and no writers are connected, so waiting for more bytes would not produce progress.

Nonblocking mode has more than one boundary

SPLICE_F_NONBLOCK applies nonblocking behavior to the pipe operations performed by splice(). The documentation also notes that the other file descriptor can still block unless that descriptor itself carries suitable nonblocking state.

This prevents a broad interpretation in which one flag converts every possible endpoint operation into a nonblocking transaction. A pipe-to-socket transfer has both pipe state and socket state. The flag addresses the former; endpoint configuration and endpoint semantics still matter for the latter.

When a nonblocking condition prevents progress, EAGAIN can be returned. A readiness-driven event loop must then preserve any bytes already resident in the pipe and resume from the correct boundary later. Reissuing the input side as though no state existed can overfill bookkeeping even though the kernel pipe still contains prior data.

SPLICE_F_MORE has a different role. When output targets a socket, it acts as a hint that more data is expected in subsequent splice operations. It does not change the transferred byte count into a completion guarantee.

tee duplicates pipe references without consuming input

Linux tee() exposes another consequence of pipe-backed transfer. It duplicates data from one pipe to another without consuming the source pipe data. The duplicated bytes can then be consumed independently by later operations.

The operation is conceptually a copy between pipes, but the kernel can implement it by adding references to the same underlying pages rather than copying payload bytes. This makes fan-out possible without first materializing the data in userspace.

The lifetime model changes accordingly. A page referenced by multiple pipe buffers cannot be treated as exclusively owned by one consumer. Each pipe has its own consumption state even when their buffer entries refer to the same underlying data.

This is a useful contrast with splice(): splice() consumes data from a pipe as it transfers it onward, while tee() preserves the source pipe content and creates another pipe-visible reference.

Compatibility is an endpoint property

Not every descriptor type or filesystem path supports every splice direction. The system call can fail with EINVAL when a target filesystem does not support splicing, and unsupported descriptor combinations can expose similar limits.

That makes capability fallback part of a portable application design on Linux. A process can use splice() where its endpoints support the operation and retain a buffered read()/write() path where they do not. The fallback changes the data path but need not change the higher-level byte-stream contract.

The same distinction applies to append mode. A target file opened with O_APPEND is rejected by splice(). An application cannot assume that a transfer primitive inherits every write mode accepted by write().

The optimization boundary is also a state boundary

splice() is often described in terms of copy avoidance, but its more consequential interface property is the explicit kernel pipe between transfer stages. The pipe has finite capacity, ordered contents, writer lifetime, reader lifetime, and blocking state. Those properties remain visible even when payload bytes never enter a userspace buffer.

A robust relay therefore tracks ownership and progress at the pipe boundary rather than treating the operation as a transparent file-descriptor-to-file-descriptor shortcut. Copy avoidance changes where bytes travel. It does not remove flow control, partial completion, endpoint capability checks, or the need to preserve pending state across retries.