A conventional file-copy loop reads bytes into a user-space buffer and then writes those bytes somewhere else. That pattern is portable and easy to understand, but sometimes the program does not need to inspect or transform the data at all. It only needs to move bytes from one file descriptor to another.
On Linux, splice() can handle that case differently. It transfers data between file descriptors while keeping the transferred data out of a user-space buffer. At least one endpoint must be a pipe, so a pipe can act as the kernel-side bridge between a source and a destination.
This is useful in programs such as proxies, file servers, logging pipelines, and other I/O-heavy tools where the application sometimes forwards bytes unchanged. It is not automatically the best choice for every copy. The important question is whether avoiding a user-space data path is worth the extra Linux-specific control flow.
The mental model is:
regular file or socket
|
| splice()
v
kernel pipe buffer
|
| splice()
v
regular file or socketThe pipe is not just an implementation detail of the example. It is part of the API contract: at least one file descriptor in each splice() call must refer to a pipe.
Start with one transfer
The function is declared in <fcntl.h> when GNU extensions are enabled:
#define _GNU_SOURCE
#define _FILE_OFFSET_BITS 64
#include <fcntl.h>
ssize_t n = splice(source_fd, NULL,
pipe_write_fd, NULL,
64 * 1024, 0);This asks the kernel to transfer up to 64 KiB from source_fd into the pipe.
The return value matters more than the requested size. A positive value is the number of bytes actually transferred. Zero means end of input. -1 means the call failed and errno explains why.
That means this is wrong:
splice(source_fd, NULL, pipe_write_fd, NULL, 64 * 1024, 0);
/* Assume 64 KiB is now in the pipe. */splice() is allowed to transfer fewer bytes than requested. Correct code must use the returned byte count and preserve any data that remains to be forwarded.
Build a complete file-copy loop
A regular file cannot be spliced directly to another regular file because neither endpoint would be a pipe. The simplest general pattern uses one pipe as an intermediate buffer:
#define _GNU_SOURCE
#define _FILE_OFFSET_BITS 64
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
static void die(const char *message) {
perror(message);
exit(EXIT_FAILURE);
}
int main(int argc, char **argv) {
if (argc != 3) {
fprintf(stderr, "usage: %s SOURCE DESTINATION\n", argv[0]);
return EXIT_FAILURE;
}
int input = open(argv[1], O_RDONLY);
if (input == -1)
die("open source");
int output = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (output == -1)
die("open destination");
int pipefd[2];
if (pipe(pipefd) == -1)
die("pipe");
for (;;) {
ssize_t loaded =
splice(input, NULL, pipefd[1], NULL, 64 * 1024, 0);
if (loaded == -1) {
if (errno == EINTR)
continue;
die("splice source");
}
if (loaded == 0)
break;
ssize_t remaining = loaded;
while (remaining > 0) {
ssize_t written =
splice(pipefd[0], NULL, output, NULL,
(size_t)remaining, 0);
if (written == -1) {
if (errno == EINTR)
continue;
die("splice destination");
}
if (written == 0) {
fprintf(stderr, "splice destination made no progress\n");
return EXIT_FAILURE;
}
remaining -= written;
}
}
if (close(pipefd[0]) == -1 || close(pipefd[1]) == -1 ||
close(input) == -1 || close(output) == -1)
die("close");
return EXIT_SUCCESS;
}The outer loop fills the pipe from the source. The inner loop drains exactly the number of bytes that were loaded.
That inner loop is essential. If the first call moves 40 KiB into the pipe but the second call moves only 12 KiB out, 28 KiB still belongs to the current chunk. Loading more source data before accounting for those bytes would make the control flow harder to reason about and, in bounded nonblocking pipelines, can contribute to avoidable backpressure.
The example uses blocking descriptors, so a temporary lack of space normally causes the call to wait. It still handles EINTR, because a signal can interrupt a blocking system call before the requested operation completes.
Understand what “without copying to user space” means
With a normal read()/write() loop, bytes are made available in a buffer owned by the process:
source -> kernel -> user buffer -> kernel -> destinationsplice() avoids moving the transferred data through that application buffer:
source -> kernel pipe buffers -> destinationThis can remove memory-copy work between kernel space and user space when the kernel and involved subsystems can support the transfer efficiently.
That does not guarantee that no physical copying happens anywhere. Filesystems, device drivers, networking paths, and kernel internals can still require copies. Even SPLICE_F_MOVE is only a hint, and current Linux documentation notes that it is effectively a no-op. Treat “zero-copy” as a useful architectural shorthand, not as a universal performance guarantee.
The practical benefit is strongest when the application forwards large amounts of data unchanged and profiling shows user-space copying to be significant. For small transfers, low-throughput tools, or code that already needs to parse the bytes, the added complexity may not help.
File offsets can be implicit or explicit
For a non-pipe endpoint, passing NULL as the offset uses and advances that file descriptor’s current file offset:
splice(input, NULL, pipefd[1], NULL, count, 0);That behavior is convenient for sequential transfers.
You can instead provide an off_t pointer for a non-pipe file descriptor:
off_t position = 1024 * 1024;
ssize_t n = splice(input, &position,
pipefd[1], NULL,
64 * 1024, 0);In that form, the descriptor’s own file offset is not changed. position is advanced by the number of bytes transferred.
This is useful when several operations share one open file description but need independent logical positions, or when code wants explicit control over where the next transfer starts.
A pipe is different. If an endpoint is a pipe, its corresponding offset argument must be NULL. Pipes are streams and do not have seekable file offsets.
Nonblocking mode changes what progress means
Event-driven programs often use nonblocking descriptors. splice() supports SPLICE_F_NONBLOCK, but the flag has a narrower meaning than its name can suggest.
It makes the pipe operations nonblocking. Other participating descriptors can still block unless they themselves were opened or configured with O_NONBLOCK.
When a nonblocking transfer cannot make progress immediately, splice() can fail with EAGAIN. That is not a fatal data error. It means the event loop should wait for the appropriate readiness condition and retry later.
A typical state machine therefore keeps track of two quantities:
source -> [bytes currently buffered in pipe] -> destination
^ |
| v
can read more? can write more?If the pipe contains data, destination writability matters because the buffered bytes need somewhere to go. If the pipe has room and the source may have data, source readability matters.
Trying to hide that state in a single “copy everything now” helper usually makes nonblocking code less reliable. Backpressure is part of the design: if the destination is slower than the source, the application must eventually stop reading from the source until buffered data drains.
A short transfer is normal, not an error
Several conditions can produce a positive return value smaller than size:
- the source currently has fewer bytes available;
- the pipe has less free capacity than requested;
- the destination accepts only part of the buffered data;
- the underlying subsystem completes a smaller transfer.
The API guarantees the returned count, not completion of the requested count.
This is the same discipline required by read(), write(), and socket I/O: structure the program around progress, not around the assumption that one system call completes one logical operation.
For a blocking file-copy loop, that means repeating calls until EOF. For a nonblocking event loop, it means preserving offsets and buffered-byte counts across readiness notifications.
Important flags are hints and controls, not magic switches
splice() accepts several flags, but only a few are commonly relevant.
SPLICE_F_NONBLOCK changes blocking behavior for pipe operations as described above.
SPLICE_F_MORE tells the kernel that more output is expected soon. It can be useful when the destination is a socket because the networking stack may use the hint similarly to MSG_MORE. It is still a hint, not a promise that packets will be combined in a particular way.
SPLICE_F_MOVE asks the kernel to move pages rather than copy them, but Linux documents it as a no-op in current implementations. Code should not depend on it for correctness or assume it improves performance.
SPLICE_F_GIFT is not used by splice() itself.
The safest default is flags 0 until the surrounding I/O design has a reason to request different behavior.
Know the cases where splice() does not fit
splice() is Linux-specific. If portability to other Unix-like systems matters, a read()/write() loop is easier to carry across platforms.
It is also a poor fit when the application must inspect, decrypt, compress, parse, hash, or otherwise transform every byte. Those operations need access to the data, so a user-space buffer is no longer avoidable.
For regular-file-to-regular-file copies, copy_file_range() may express the intent more directly because it does not require the application to create an intermediate pipe. Filesystem support and cross-filesystem behavior still need to be handled, but the API matches that specific operation better.
For sending regular-file contents to a socket, sendfile() can be simpler when its endpoint restrictions fit the program.
These APIs overlap, but they are not interchangeable abstractions:
- use
read()/write()for portability and when user space needs the bytes; - consider
copy_file_range()for file-to-file copies; - consider
sendfile()for supported file-to-output transfers such as serving file data; - consider
splice()when a Linux pipeline naturally involves pipes or when you need to bridge file descriptors without exposing the payload to user space.
Handle failure paths deliberately
Production code needs more cleanup than the compact example.
If opening the destination fails after opening the source, the source should be closed before returning. If pipe() fails, both already-open files should be closed. A larger program should usually centralize cleanup so later changes do not introduce descriptor leaks.
Also pay attention to destination semantics. The example uses O_TRUNC, which destroys existing destination contents as soon as open() succeeds. That may be acceptable for a copy utility, but it is not an atomic replacement strategy. If a partially written destination would be harmful, write to a temporary file in the same filesystem, synchronize as required by the application’s durability contract, and rename it into place only after the transfer succeeds.
splice() does not make partial-output or crash-consistency problems disappear. It changes how bytes move, not the higher-level correctness requirements around those bytes.
Measure before treating it as a performance optimization
Avoiding a user-space buffer can reduce CPU and memory-bandwidth work, but end-to-end throughput may still be limited by storage, the network, filesystem behavior, pipe capacity, or downstream backpressure.
A splice() implementation also has costs: extra system-call logic, a pipe to manage, Linux-specific code paths, and more complicated nonblocking state.
Benchmark the real workload. Compare against the simplest correct alternative with representative file sizes, concurrency, storage, and network conditions. If ordinary buffered I/O is already below the noise floor of the workload, a more specialized transfer path may make the code harder to maintain without producing a meaningful benefit.
Conclusion
splice() is best understood as a Linux primitive for moving bytes through pipe buffers without routing the payload through a user-space data buffer.
The key rules are simple but important: at least one endpoint must be a pipe, every call can make partial progress, pipe endpoints require NULL offsets, and nonblocking pipelines must model backpressure explicitly.
Use it when the application is forwarding data unchanged and the Linux-specific optimization is justified. When portability, transformation, or simplicity matters more, conventional buffered I/O is often the better engineering choice.