Binary-processing code often needs only a small region of a larger byte buffer. A normal bytes or bytearray slice is convenient, but it creates a new object containing copied data. When buffers are large or slicing happens repeatedly on a hot path, those copies can become unnecessary allocation and memory traffic.

Python’s memoryview provides a different model. It exposes data from an object that supports the buffer protocol and lets Python code work with that data without first copying it into a new bytes object.

That makes memoryview useful for binary parsers, networking code, image and numeric buffers, and APIs that already accept buffer-protocol objects. It is not automatically faster for every workload: views have their own object-management cost, retain the underlying storage, and sometimes must eventually be converted to bytes.

Start with a view instead of a copied slice

Consider a packet stored in an immutable bytes object:

packet = b"HEADpayloadTAIL"

copied = packet[4:11]
view = memoryview(packet)[4:11]

print(copied)
print(view.tobytes())

Both selections represent b"payload", but they reach that result differently.

packet[4:11] constructs a new bytes object containing the selected bytes. The memoryview slice creates another view referring to the underlying buffer. Calling view.tobytes() then performs a copy because the requested result is an independent bytes object.

The zero-copy benefit therefore exists while downstream code can continue working with the view or another buffer-protocol interface.

Understand the buffer protocol boundary

memoryview can only wrap objects that export the buffer protocol.

Common built-in examples include bytes and bytearray. Other standard-library types, such as array.array, can expose elements larger than one byte.

from array import array

values = array("i", [10, 20, 30])
view = memoryview(values)

print(view.format)
print(view.itemsize)
print(view.tolist())

A memory view is not inherently a sequence of raw bytes. Its element format and item size come from the exporter.

That distinction matters when writing generic code. len(view) describes elements, while view.nbytes reports the logical number of bytes represented by the view. For a simple byte-oriented view those values are often equal, but code should not assume they are equivalent for every exporter.

Slicing a memoryview creates another view

A one-dimensional memory view can be sliced without copying the underlying buffer:

data = bytearray(b"abcdefgh")
whole = memoryview(data)
middle = whole[2:6]

middle[0] = ord("X")

print(data)

The result is:

bytearray(b'abXdefgh')

middle still refers to storage owned by data, so changing a writable view changes the exporter.

This shared-storage behavior is the main feature of memoryview, but it also means a view is not an isolated snapshot. Code receiving a writable view may observe later changes or make changes itself.

Mutability comes from the exporter

A view over immutable bytes is read-only:

view = memoryview(b"abc")

print(view.readonly)

A view over a bytearray is writable:

data = bytearray(b"abc")
view = memoryview(data)

view[1] = ord("Z")

print(data)

The second example changes data to bytearray(b'aZc').

For a one-dimensional writable view, slice assignment is also possible when the source and destination structures are compatible:

data = bytearray(b"abcdef")
view = memoryview(data)

view[1:4] = b"XYZ"

print(data)

This produces bytearray(b'aXYZef').

A memory view does not provide general resizing through slice assignment. Replacing a three-byte region with a different-sized value is not the same operation as resizing a bytearray.

Exported views can restrict resizing

A writable exporter may normally support operations that change its size, but active buffer exports can prevent those operations.

For example:

data = bytearray(b"abc")
view = memoryview(data)

try:
    data.extend(b"d")
except BufferError:
    print("cannot resize while the buffer is exported")

view.release()
data.extend(b"d")

While the view is active, CPython’s bytearray prevents resizing that would invalidate the exported buffer. Releasing the view removes that particular export.

Do not design code that depends on resizing a mutable buffer while consumers still hold views into it. Establish clear ownership: either finish using the views before resizing, or allocate a new buffer for the resized data.

Release views when the exporter needs them gone

Most ordinary memory views can simply be allowed to become unreachable, but memoryview.release() explicitly releases the exported buffer.

After release, operations on the view are forbidden:

view = memoryview(b"abc")
view.release()

try:
    view[0]
except ValueError:
    print("view has been released")

A memory view also supports the context-manager protocol:

data = bytearray(b"abc")

with memoryview(data) as view:
    print(view[0])

Leaving the with block releases the view.

Explicit release is especially useful when an exporter imposes restrictions while a view exists. It also makes the lifetime boundary visible in code instead of relying on garbage collection or reference-counting behavior.

A small view can keep a large buffer alive

Zero-copy slicing trades copying for shared lifetime.

Suppose a program receives a large buffer and stores only a tiny view into it:

def select_header(message: bytes) -> memoryview:
    return memoryview(message)[:16]

The returned view still depends on the exporting object. Keeping that small view alive can therefore keep the much larger original buffer alive too.

This can be the wrong trade-off for long-lived caches or queues. If the program needs 16 bytes for hours but the original message is several megabytes, copying those 16 bytes may use less memory overall:

def select_header(message: bytes) -> bytes:
    return bytes(memoryview(message)[:16])

Zero-copy is a tool for controlling copies, not a rule that copying is always bad.

Cast a buffer without copying its bytes

memoryview.cast() can reinterpret compatible buffer data with another element format without copying the buffer.

For example, four bytes can be viewed as four unsigned byte values:

data = bytearray([1, 2, 3, 4])
view = memoryview(data)
numbers = view.cast("B")

print(numbers.tolist())

Casting is constrained. The destination uses a single native format from struct syntax, one side of the cast must be a byte format, and the total byte length must remain compatible with the requested representation.

Do not use cast() as a portable binary-file decoder when the format defines a specific byte order. Native-format interpretation can depend on the machine. For external binary protocols and file formats, struct.unpack_from() with an explicit byte-order prefix is often clearer and safer.

Use struct.unpack_from for protocol fields

struct.unpack_from() can read directly from a buffer-protocol object at an offset, so a parser does not need to slice each field into a new bytes object first:

import struct


def parse_header(packet: bytes) -> tuple[int, int]:
    if len(packet) < 6:
        raise ValueError("header is incomplete")

    version, payload_length = struct.unpack_from(">HI", packet, 0)
    return version, payload_length

The format >HI specifies big-endian byte order, an unsigned short, and an unsigned int. Its size is six bytes.

This approach is often preferable to combining memoryview.cast() with assumptions about native representation when the data format itself defines endianness.

Pass views only to APIs that accept buffers

A memory view is useful only while the next operation can consume it appropriately.

Many binary APIs accept bytes-like or buffer-protocol objects, but not every function that accepts bytes necessarily accepts memoryview. Check the API contract rather than assuming interchangeability.

If an API requires an actual immutable bytes object, conversion is explicit:

payload = memoryview(b"HEADpayload")[4:]
result = payload.tobytes()

At that boundary, a copy is expected.

This suggests a practical design rule: preserve views through internal processing where APIs support them, then materialize bytes only at a boundary that genuinely requires ownership of an independent bytes object.

Avoid accidental copies in parser helpers

A parser can lose the benefit of memoryview if every helper immediately converts its input.

For example:

def checksum_input(data) -> bytes:
    return bytes(data)

Passing a view to this helper still allocates a new bytes object.

Instead, decide what the helper actually requires. If it only needs to read data and the called APIs accept buffers, keep the parameter buffer-oriented. If it needs to retain an immutable snapshot after the caller may mutate the source, making a copy is correct and should be intentional.

The important distinction is ownership. A view borrows storage; a copied bytes value owns an independent immutable snapshot.

Be careful with non-contiguous views

Not every memory view represents one contiguous byte range.

A strided slice can select every second element:

view = memoryview(b"abcdef")[::2]

print(view.tolist())
print(view.contiguous)

The logical result contains a, c, and e, but those bytes are not adjacent in the original buffer.

Some consumers require contiguous memory. A memory view can expose properties such as contiguous, c_contiguous, and f_contiguous so code can inspect the layout.

If a downstream operation requires contiguous bytes, materializing with tobytes() may be necessary. Zero-copy behavior cannot be preserved through an interface whose contract requires a contiguous representation that the current view does not provide.

Do not use views blindly for tiny data

Creating a memoryview object has overhead. For a small, infrequent slice, ordinary slicing is simpler and may be entirely appropriate:

prefix = packet[:4]

Use a view when avoiding copies matters enough to justify borrowed-storage semantics. Good candidates include:

  • repeated slicing of large buffers;
  • binary parsers that pass subranges through several functions;
  • large mutable buffers shared with low-level APIs;
  • numeric or image data already exposed through the buffer protocol.

Measure performance-sensitive code. Replacing every bytes slice with a view can make interfaces more complicated without producing a meaningful improvement.

Common pitfalls

Assuming a view owns its data

A memory view references exported storage. Keep the exporter lifetime and mutation rules in mind.

Converting to bytes immediately

bytes(view) and view.tobytes() create bytes output. If conversion happens immediately after creating the view, the view may not have avoided meaningful copying.

Resizing a bytearray with live views

An active export can prevent size-changing operations. Release or discard views before resizing the underlying buffer.

Confusing elements with bytes

Views can expose elements wider than one byte. Use properties such as itemsize, format, and nbytes when the element representation matters.

Using native casts for network formats

External formats often specify endianness and exact field sizes. Use parsing tools such as struct with an explicit format instead of relying on the machine’s native representation.

Retaining tiny slices of huge buffers

A small view can extend the lifetime of a large exporter. Copy a small long-lived result when that reduces retained memory.

Choose ownership deliberately

The central design question is not “Can this copy be removed?” It is “Who should own these bytes?”

Use a copied bytes value when the consumer needs an independent immutable snapshot, a long lifetime unrelated to the source buffer, or an API specifically requires bytes.

Use memoryview when code can safely borrow existing storage and the avoided copying matters.

That ownership distinction makes zero-copy code easier to reason about. memoryview is most effective when buffer lifetimes are short and explicit, mutation is controlled, and downstream APIs can continue operating on buffer-backed data without forcing an immediate conversion.