Many Python programs need to move bytes between objects that behave like files without caring whether either side is an ordinary disk file. The source might be a decompressor, an uploaded file, an in-memory buffer, or a response body. The destination might be a temporary file, another buffer, or a wrapper that transforms data as it is written.
For that job, shutil.copyfileobj() is a small but useful standard-library primitive. It copies from one file-like object to another and lets the objects themselves define where the bytes ultimately come from and go.
The function is simple, but several details matter in production code: it starts at the source’s current position, its length argument controls buffer size rather than total bytes copied, a negative length changes the memory behavior, and completion does not imply that the destination has been flushed.
Copy between open file-like objects
A basic copy looks like this:
import shutil
with open("input.bin", "rb") as source:
with open("output.bin", "wb") as destination:
shutil.copyfileobj(source, destination)Unlike shutil.copyfile(), this API operates on already-open file-like objects. That distinction is useful when opening and closing resources belongs to other code or when the objects are not filesystem files at all.
For example, io.BytesIO works because it provides the read and write methods expected by the copy operation:
import io
import shutil
source = io.BytesIO(b"header\npayload\n")
destination = io.BytesIO()
shutil.copyfileobj(source, destination)
assert destination.getvalue() == b"header\npayload\n"copyfileobj() does not close either object. The caller retains ownership of their lifecycle.
The source’s current position is significant
The copy begins wherever the source is currently positioned. It does not automatically rewind to byte zero.
import io
import shutil
source = io.BytesIO(b"prefix:payload")
destination = io.BytesIO()
source.seek(len(b"prefix:"))
shutil.copyfileobj(source, destination)
assert destination.getvalue() == b"payload"This behavior is useful when a parser has already consumed a header and the remaining bytes should be forwarded somewhere else. It can also produce subtle bugs when code assumes that passing an existing file object means copying the entire underlying file.
If the operation requires the whole seekable source, make that precondition explicit:
source.seek(0)
shutil.copyfileobj(source, destination)Do not add seek(0) mechanically. Network streams, pipes, decompression readers, and many other file-like objects are intentionally not seekable.
length is a buffer size, not a copy limit
The optional length parameter is easy to misread. A positive value controls how much data is requested per iteration; it does not mean “copy at most this many bytes.”
import shutil
with open("input.bin", "rb") as source:
with open("output.bin", "wb") as destination:
shutil.copyfileobj(source, destination, length=128 * 1024)That call still copies until the source reaches end-of-file. It simply uses a 128 KiB buffer for the loop.
This distinction matters when the source is untrusted. Passing length=10 * 1024 * 1024 does not impose a 10 MiB upload or response limit. If the source keeps producing data, the function keeps copying it.
Avoid negative length for unbounded sources
A negative length has special semantics: instead of repeatedly copying chunks, copyfileobj() reads the source without the normal chunking loop. That can be attractive for a small, known in-memory object, but it is a poor default for data whose size is unknown.
The normal chunked behavior keeps memory use bounded by the copy buffer plus whatever buffering the source and destination perform internally. A negative length can cause a large source to be read in one operation and therefore removes that protection.
For files, uploads, decompressed content, or remote responses whose size is not tightly controlled, keep the default chunked behavior or choose a positive buffer size.
Flushing is a separate responsibility
Finishing the copy means the function has passed the source data to the destination object’s write() method. It does not guarantee that a buffered destination has flushed those bytes to its underlying resource.
That matters when another operation needs to observe the destination immediately:
import shutil
import tempfile
with tempfile.TemporaryFile(mode="w+b") as destination:
with open("input.bin", "rb") as source:
shutil.copyfileobj(source, destination)
destination.flush()
destination.seek(0)
copied = destination.read()For a normal buffered file, seek() itself coordinates buffered state, but calling flush() makes the ownership boundary explicit when subsequent code, another handle, or an external component must observe written data.
Closing a normal file also flushes its Python-level buffers as part of closing. If the destination remains open, decide deliberately whether the next consumer requires a flush.
A flush is not the same as durable storage. If an application requires data to survive an operating-system crash or power loss, that is a different requirement involving APIs such as os.fsync() and filesystem-specific guarantees.
Use context managers to keep resource ownership obvious
Because copyfileobj() does not close its arguments, resource cleanup should remain visible around the copy:
import shutil
from pathlib import Path
def copy_payload(source_path: Path, destination_path: Path) -> None:
with source_path.open("rb") as source:
with destination_path.open("wb") as destination:
shutil.copyfileobj(source, destination)This structure also gives exceptions a straightforward path. A read failure or write failure propagates from the copy, while the with statements still close both files.
Be more careful when the objects were passed into your function by a caller. In that case, closing them may violate the caller’s ownership expectations:
import shutil
from typing import BinaryIO
def copy_stream(source: BinaryIO, destination: BinaryIO) -> None:
shutil.copyfileobj(source, destination)A useful convention is that the code that opens a resource is normally the code responsible for closing it, unless the API contract explicitly transfers ownership.
Write a custom loop when you need a hard byte limit
If you must reject a stream after a maximum number of bytes, use a bounded loop rather than treating copyfileobj(length=...) as a quota.
from typing import BinaryIO
def copy_at_most(
source: BinaryIO,
destination: BinaryIO,
*,
max_bytes: int,
chunk_size: int = 64 * 1024,
) -> int:
if max_bytes < 0:
raise ValueError("max_bytes must be non-negative")
if chunk_size <= 0:
raise ValueError("chunk_size must be positive")
copied = 0
while copied < max_bytes:
chunk = source.read(min(chunk_size, max_bytes - copied))
if not chunk:
return copied
destination.write(chunk)
copied += len(chunk)
extra = source.read(1)
if extra:
raise ValueError("source exceeds maximum size")
return copiedReading one extra byte distinguishes a source that is exactly at the limit from one that exceeds it. There is an important consequence: on the error path, that byte has been consumed from the source. If callers need to recover and continue reading the original stream, the API needs a different contract, buffering strategy, or a seekable source that can be repositioned.
Also decide what should happen to the partially written destination when the limit is exceeded. A file being published to users may need to be deleted or written to a temporary path and renamed only after validation succeeds.
Do not confuse stream copying with filesystem metadata copying
copyfileobj() moves data between file-like objects. It does not preserve filesystem metadata such as permission bits, timestamps, ownership, or extended metadata.
If the actual task is “copy this filesystem file,” higher-level functions such as shutil.copy() or shutil.copy2() may better express the intent. Even those functions cannot preserve every kind of metadata on every platform, so choose them according to the metadata guarantees the application actually needs.
Conversely, when one side is not a path at all, copyfileobj() is often the more natural abstraction because it avoids pretending the operation is fundamentally about filenames.
Buffer size is a tuning parameter, not a correctness feature
A larger positive length can reduce the number of Python-level read and write calls, while a smaller one can reduce the amount of data held per iteration. The best value depends on the source, destination, wrappers, storage, and surrounding workload.
Avoid choosing a very large buffer merely because the source file can be large. Chunking is specifically what lets a multi-gigabyte stream be copied without loading the entire stream into memory.
Start with the default unless measurement shows that the copy loop is an important bottleneck. If you tune it, benchmark the real workload rather than assuming that a larger buffer is always faster.
Treat file-like behavior as part of the contract
Python’s file-like abstraction is intentionally broad. A source’s read() may involve disk I/O, decompression, network activity, or custom application logic. A destination’s write() may buffer, transform, compress, or forward data.
That flexibility is why copyfileobj() is useful, but it also means the helper cannot provide guarantees that the objects themselves do not provide. Timeouts, cancellation, durability, atomic publication, maximum-size enforcement, and retry semantics all belong to the surrounding design.
When those requirements exist, make them explicit instead of expecting the copy helper to infer them.
Conclusion
shutil.copyfileobj() is a good fit when the job is to copy from one open file-like object to another until the source reaches EOF. Its simplicity is strongest when resource ownership and stream policy are handled clearly outside the function.
Remember the boundaries: copying starts at the source’s current position, a positive length sets the buffer size rather than a byte quota, a negative length can remove chunked memory protection, and the destination may still need to be flushed after copying.
Once a workflow needs stronger guarantees such as a hard size limit, atomic publication, durability, or recovery after partial failure, keep copyfileobj() for the simple part or replace it with a loop whose contract directly represents those requirements.