Repository: jcrist/msgspec
Pull request #624 (2024-01-04 08:40:25)

feat: support deserializing to memoryview
The implementation enables zero-copy functionality when decoding msgpack by supporting deserialization directly to a memoryview. This is very useful for decoding large numpy array.

jcrist: Thanks for the PR! I've taken the liberty to finish it up - adding support for new types needs to be done in several places.

Note that zero-copy deserialization of buffers can also be handled through [Ext](https://jcristharif.com/msgspec/extending.html#defining-a-custom-extension-messagepack-only) types. When decoding an `Ext`, the buffer passed to `ext_hook` is a `memoryview` into the original backing message. This was intended to support decoding large buffer-backed objects (like numpy arrays) in a zero-copy fashion.

Patch:
diff --git a/docs/source/api.rst b/docs/source/api.rst
index 92aed85..f7602e0 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -150,6 +150,7 @@ Inspect
 .. autoclass:: StrType
 .. autoclass:: BytesType
 .. autoclass:: ByteArrayType
+.. autoclass:: MemoryViewType
 .. autoclass:: DateTimeType
 .. autoclass:: TimeType
 .. autoclass:: DateType
diff --git a/docs/source/supported-types.rst b/docs/source/supported-types.rst
index a893c93..e268f26 100644
--- a/docs/source/supported-types.rst
+++ b/docs/source/supported-types.rst
@@ -244,6 +244,23 @@ Bytes-like objects map to base64-encoded strings in JSON, YAML, and TOML. The
     >>> msgspec.json.decode(msg, type=bytearray)
     bytearray(b'\xf0\x9d\x84\x9e')
 
+
+.. note::
+
+    For the ``msgpack`` protocol, `memoryview` objects will be decoded as
+    direct views into the larger buffer containing the input message being
+    decoded. This may be useful for implementing efficient zero-copy handling
+    of large binary messages, but is also a potential footgun. As long as a
+    decoded ``memoryview`` remains in memory, the input message buffer will
+    also be persisted, potentially resulting in unnecessarily large memory
+    usage. The usage of ``memoryview`` types in this manner is considered an
+    advanced topic, and should only be used when you know their usage will
+    result in a performance benefit.
+
+    For all other protocols `memoryview` objects will still result in a copy,
+    and will likely be slightly slower than decoding into a `bytes` object
+
+
 ``datetime``
 ------------
 
diff --git a/msgspec/_core.c b/msgspec/_core.c
index 87ed5ef..cd6ba82 100644
--- a/msgspec/_core.c
+++ b/msgspec/_core.c
@@ -2499,32 +2499,33 @@ static PyTypeObject Field_Type = {
 #define MS_TYPE_STR                 (1ull << 5)
 #define MS_TYPE_BYTES               (1ull << 6)
 #define MS_TYPE_BYTEARRAY           (1ull << 7)
-#define MS_TYPE_DATETIME            (1ull << 8)
-#define MS_TYPE_DATE                (1ull << 9)
-#define MS_TYPE_TIME                (1ull << 10)
-#define MS_TYPE_TIMEDELTA           (1ull << 11)
-#define MS_TYPE_UUID                (1ull << 12)
-#define MS_TYPE_DECIMAL             (1ull << 13)
-#define MS_TYPE_EXT                 (1ull << 14)
-#define MS_TYPE_STRUCT              (1ull << 15)
-#define MS_TYPE_STRUCT_ARRAY        (1ull << 16)
-#define MS_TYPE_STRUCT_UNION        (1ull << 17)
-#define MS_TYPE_STRUCT_ARRAY_UNION  (1ull << 18)
-#define MS_TYPE_ENUM                (1ull << 19)
-#define MS_TYPE_INTENUM             (1ull << 20)
-#define MS_TYPE_CUSTOM              (1ull << 21)
-#define MS_TYPE_CUSTOM_GENERIC      (1ull << 22)
-#define MS_TYPE_DICT                ((1ull << 23) | (1ull << 24))
-#define MS_TYPE_LIST                (1ull << 25)
-#define MS_TYPE_SET                 (1ull << 26)
-#define MS_TYPE_FROZENSET           (1ull << 27)
-#define MS_TYPE_VARTUPLE            (1ull << 28)
-#define MS_TYPE_FIXTUPLE            (1ull << 29)
-#define MS_TYPE_INTLITERAL          (1ull << 30)
-#define MS_TYPE_STRLITERAL          (1ull << 31)
-#define MS_TYPE_TYPEDDICT           (1ull << 32)
-#define MS_TYPE_DATACLASS           (1ull << 33)
-#define MS_TYPE_NAMEDTUPLE          (1ull << 34)
+#define MS_TYPE_MEMORYVIEW          (1ull << 8)
+#define MS_TYPE_DATETIME            (1ull << 9)
+#define MS_TYPE_DATE                (1ull << 10)
+#define MS_TYPE_TIME                (1ull << 11)
+#define MS_TYPE_TIMEDELTA           (1ull << 12)
+#define MS_TYPE_UUID                (1ull << 13)
+#define MS_TYPE_DECIMAL             (1ull << 14)
+#define MS_TYPE_EXT                 (1ull << 15)
+#define MS_TYPE_STRUCT              (1ull << 16)
+#define MS_TYPE_STRUCT_ARRAY        (1ull << 17)
+#define MS_TYPE_STRUCT_UNION        (1ull << 18)
+#define MS_TYPE_STRUCT_ARRAY_UNION  (1ull << 19)
+#define MS_TYPE_ENUM                (1ull << 20)
+#define MS_TYPE_INTENUM             (1ull << 21)
+#define MS_TYPE_CUSTOM              (1ull << 22)
+#define MS_TYPE_CUSTOM_GENERIC      (1ull << 23)
+#define MS_TYPE_DICT                ((1ull << 24) | (1ull << 25))
+#define MS_TYPE_LIST                (1ull << 26)
+#define MS_TYPE_SET                 (1ull << 27)
+#define MS_TYPE_FROZENSET           (1ull << 28)
+#define MS_TYPE_VARTUPLE            (1ull << 29)
+#define MS_TYPE_FIXTUPLE            (1ull << 30)
+#define MS_TYPE_INTLITERAL          (1ull << 31)
+#define MS_TYPE_STRLITERAL          (1ull << 32)
+#define MS_TYPE_TYPEDDICT           (1ull << 33)
+#define MS_TYPE_DATACLASS           (1ull << 34)
+#define MS_TYPE_NAMEDTUPLE          (1ull << 35)
 /* Constraints */
 #define MS_CONSTR_INT_MIN           (1ull << 42)
 #define MS_CONSTR_INT_MAX           (1ull << 43)
@@ -3100,7 +3101,7 @@ typenode_simple_repr(TypeNode *self) {
     if (self->types & (MS_TYPE_STR | MS_TYPE_ENUM | MS_TYPE_STRLITERAL)) {
         if (!strbuilder_extend_literal(&builder, "str")) return NULL;
     }
-    if (self->types & (MS_TYPE_BYTES | MS_TYPE_BYTEARRAY)) {
+    if (self->types & (MS_TYPE_BYTES | MS_TYPE_BYTEARRAY | MS_TYPE_MEMORYVIEW)) {
         if (!strbuilder_extend_literal(&builder, "bytes")) return NULL;
     }
     if (self->types & MS_TYPE_DATETIME) {
@@ -3832,7 +3833,7 @@ typenode_collect_check_invariants(TypeNodeCollectState *state) {
     if (ms_popcount(
             state->types & (
                 MS_TYPE_STR | MS_TYPE_STRLITERAL | MS_TYPE_ENUM |
-                MS_TYPE_BYTES | MS_TYPE_BYTEARRAY |
+                MS_TYPE_BYTES | MS_TYPE_BYTEARRAY | MS_TYPE_MEMORYVIEW |
                 MS_TYPE_DATETIME | MS_TYPE_DATE | MS_TYPE_TIME |
                 MS_TYPE_TIMEDELTA | MS_TYPE_UUID | MS_TYPE_DECIMAL
             )
@@ -4623,6 +4624,10 @@ typenode_collect_type(TypeNodeCollectState *state, PyObject *obj) {
         state->types |= MS_TYPE_BYTEARRAY;
         kind = CK_BYTES;
     }
+    else if (t == (PyObject *)(&PyMemoryView_Type)) {
+        state->types |= MS_TYPE_MEMORYVIEW;
+        kind = CK_BYTES;
+    }
     else if (t == (PyObject *)(PyDateTimeAPI->DateTimeType)) {
         state->types |= MS_TYPE_DATETIME;
         kind = CK_TIME;
@@ -14464,6 +14469,16 @@ mpack_decode_bin(
     else if (type->types & MS_TYPE_UUID) {
         return ms_decode_uuid_from_bytes(s, size, path);
     }
+    else if (type->types & MS_TYPE_MEMORYVIEW) {
+        PyObject *view = PyMemoryView_GetContiguous(
+            self->buffer_obj, PyBUF_READ, 'C'
+        );
+        if (view == NULL) return NULL;
+        Py_buffer *buffer = PyMemoryView_GET_BUFFER(view);
+        buffer->buf = s;
+        buffer->len = size;
+        return view;
+    }
 
     return ms_validation_error("bytes", type, path);
 }
@@ -16494,11 +16509,19 @@ json_decode_binary(
         if (out == NULL) return NULL;
         bin_buffer = PyBytes_AS_STRING(out);
     }
-    else {
+    else if (type->types & MS_TYPE_BYTEARRAY) {
         out = PyByteArray_FromStringAndSize(NULL, bin_size);
         if (out == NULL) return NULL;
         bin_buffer = PyByteArray_AS_STRING(out);
     }
+    else {
+        PyObject *temp = PyBytes_FromStringAndSize(NULL, bin_size);
+        if (temp == NULL) return NULL;
+        bin_buffer = PyBytes_AS_STRING(temp);
+        out = PyMemoryView_FromObject(temp);
+        Py_DECREF(temp);
+        if (out == NULL) return NULL;
+    }
 
     int quad = 0;
     uint8_t left_c = 0;
@@ -16577,7 +16600,11 @@ json_decode_string(JSONDecoderState *self, TypeNode *type, PathNode *path) {
     else if (MS_UNLIKELY(type->types & MS_TYPE_DECIMAL)) {
         return ms_decode_decimal(view, size, is_ascii, path, NULL);
     }
-    else if (MS_UNLIKELY(type->types & (MS_TYPE_BYTES | MS_TYPE_BYTEARRAY))) {
+    else if (
+        MS_UNLIKELY(type->types &
+            (MS_TYPE_BYTES | MS_TYPE_BYTEARRAY | MS_TYPE_MEMORYVIEW)
+        )
+    ) {
         return json_decode_binary(view, size, type, path);
     }
     else if (MS_UNLIKELY(type->types & (MS_TYPE_ENUM | MS_TYPE_STRLITERAL))) {
@@ -16636,7 +16663,7 @@ json_decode_dict_key_fallback(
     else if (type->types & MS_TYPE_TIMEDELTA) {
         return ms_decode_timedelta(view, size, type, path);
     }
-    else if (type->types & MS_TYPE_BYTES) {
+    else if (type->types & (MS_TYPE_BYTES | MS_TYPE_MEMORYVIEW)) {
         return json_decode_binary(view, size, type, path);
     }
     else {
@@ -19662,6 +19689,12 @@ convert_str_uncommon(
     ) {
         return json_decode_binary(view, size, type, path);
     }
+    else if (
+        (type->types & MS_TYPE_MEMORYVIEW)
+        && !(self->builtin_types & MS_BUILTIN_MEMORYVIEW)
+    ) {
+        return json_decode_binary(view, size, type, path);
+    }
     return ms_validation_error("str", type, path);
 }
 
@@ -19691,18 +19724,23 @@ static PyObject *
 convert_bytes(
     ConvertState *self, PyObject *obj, TypeNode *type, PathNode *path
 ) {
-    if (type->types & (MS_TYPE_BYTES | MS_TYPE_BYTEARRAY)) {
+    if (type->types & (MS_TYPE_BYTES | MS_TYPE_BYTEARRAY | MS_TYPE_MEMORYVIEW)) {
         if (!ms_passes_bytes_constraints(PyBytes_GET_SIZE(obj), type, path)) {
             return NULL;
         }
         if (type->types & MS_TYPE_BYTES) {
             return PyBytes_FromObject(obj);
         }
-        return PyByteArray_FromObject(obj);
+        else if (type->types & MS_TYPE_BYTEARRAY) {
+            return PyByteArray_FromObject(obj);
+        }
+        else {
+            return PyMemoryView_FromObject(obj);
+        }
     }
     if (
-            (type->types & MS_TYPE_UUID) &&
-            !(self->builtin_types & MS_BUILTIN_UUID)
+        (type->types & MS_TYPE_UUID) &&
+        !(self->builtin_types & MS_BUILTIN_UUID)
     ) {
         return ms_decode_uuid_from_bytes(
             PyBytes_AS_STRING(obj), PyBytes_GET_SIZE(obj), path
@@ -19715,7 +19753,7 @@ static PyObject *
 convert_bytearray(
     ConvertState *self, PyObject *obj, TypeNode *type, PathNode *path
 ) {
-    if (type->types & (MS_TYPE_BYTES | MS_TYPE_BYTEARRAY)) {
+    if (type->types & (MS_TYPE_BYTES | MS_TYPE_BYTEARRAY | MS_TYPE_MEMORYVIEW)) {
         if (!ms_passes_bytes_constraints(PyByteArray_GET_SIZE(obj), type, path)) {
             return NULL;
         }
@@ -19723,11 +19761,16 @@ convert_bytearray(
             Py_INCREF(obj);
             return obj;
         }
-        return PyBytes_FromObject(obj);
+        else if (type->types & MS_TYPE_BYTES) {
+            return PyBytes_FromObject(obj);
+        }
+        else {
+            return PyMemoryView_FromObject(obj);
+        }
     }
     if (
-            (type->types & MS_TYPE_UUID) &&
-            !(self->builtin_types & MS_BUILTIN_UUID)
+        (type->types & MS_TYPE_UUID) &&
+        !(self->builtin_types & MS_BUILTIN_UUID)
     ) {
         return ms_decode_uuid_from_bytes(
             PyByteArray_AS_STRING(obj), PyByteArray_GET_SIZE(obj), path
@@ -19736,6 +19779,39 @@ convert_bytearray(
     return ms_validation_error("bytes", type, path);
 }
 
+static PyObject *
+convert_memoryview(
+    ConvertState *self, PyObject *obj, TypeNode *type, PathNode *path
+) {
+    if (type->types & (MS_TYPE_BYTES | MS_TYPE_BYTEARRAY | MS_TYPE_MEMORYVIEW)) {
+        Py_ssize_t len = PyMemoryView_GET_BUFFER(obj)->len;
+        if (!ms_passes_bytes_constraints(len, type, path)) return NULL;
+        if (type->types & MS_TYPE_MEMORYVIEW) {
+            Py_INCREF(obj);
+            return obj;
+        }
+        else if (type->types & MS_TYPE_BYTES) {
+            return PyBytes_FromObject(obj);
+        }
+        else {
+            return PyByteArray_FromObject(obj);
+        }
+    }
+    if (
+        (type->types & MS_TYPE_UUID) &&
+        !(self->builtin_types & MS_BUILTIN_UUID)
+    ) {
+        Py_buffer buffer;
+        if (PyObject_GetBuffer(obj, &buffer, PyBUF_CONTIG_RO) < 0) return NULL;
+        PyObject *out = ms_decode_uuid_from_bytes(
+            buffer.buf, buffer.len, path
+        );
+        PyBuffer_Release(&buffer);
+        return out;
+    }
+    return ms_validation_error("bytes", type, path);
+}
+
 static PyObject *
 convert_datetime(
     ConvertState *self, PyObject *obj, TypeNode *type, PathNode *path
@@ -20857,6 +20933,9 @@ convert(
     else if (pytype == &PyByteArray_Type) {
         return convert_bytearray(self, obj, type, path);
     }
+    else if (pytype == &PyMemoryView_Type) {
+        return convert_memoryview(self, obj, type, path);
+    }
     else if (pytype == PyDateTimeAPI->DateTimeType) {
         return convert_datetime(self, obj, type, path);
     }
diff --git a/msgspec/_json_schema.py b/msgspec/_json_schema.py
index 1456b2a..be506e3 100644
--- a/msgspec/_json_schema.py
+++ b/msgspec/_json_schema.py
@@ -247,7 +247,7 @@ class _SchemaGenerator:
                 schema["minLength"] = t.min_length
             if t.pattern is not None:
                 schema["pattern"] = t.pattern
-        elif isinstance(t, (mi.BytesType, mi.ByteArrayType)):
+        elif isinstance(t, (mi.BytesType, mi.ByteArrayType, mi.MemoryViewType)):
             schema["type"] = "string"
             schema["contentEncoding"] = "base64"
             if t.max_length is not None:
diff --git a/msgspec/inspect.py b/msgspec/inspect.py
index 3838a10..2f5a804 100644
--- a/msgspec/inspect.py
+++ b/msgspec/inspect.py
@@ -53,6 +53,7 @@ __all__ = (
     "StrType",
     "BytesType",
     "ByteArrayType",
+    "MemoryViewType",
     "DateTimeType",
     "TimeType",
     "DateType",
@@ -222,6 +223,23 @@ class ByteArrayType(Type):
     max_length: Union[int, None] = None
 
 
+class MemoryViewType(Type):
+    """A type corresponding to `memoryview`.
+
+    Parameters
+    ----------
+    min_length: int, optional
+        If set, an instance of this type must have length greater than or equal
+        to ``min_length``.
+    max_length: int, optional
+        If set, an instance of this type must have length less than or equal
+        to ``max_length``.
+    """
+
+    min_length: Union[int, None] = None
+    max_length: Union[int, None] = None
+
+
 class DateTimeType(Type):
     """A type corresponding to `datetime.datetime`.
 
@@ -803,6 +821,8 @@ class _Translator:
             return BytesType(min_length=min_length, max_length=max_length)
         elif t is bytearray:
             return ByteArrayType(min_length=min_length, max_length=max_length)
+        elif t is memoryview:
+            return MemoryViewType(min_length=min_length, max_length=max_length)
         elif t is datetime.datetime:
             return DateTimeType(tz=tz)
         elif t is datetime.time:


Test patch:
diff --git a/tests/test_constraints.py b/tests/test_constraints.py
index db6b1d5..6dc8611 100644
--- a/tests/test_constraints.py
+++ b/tests/test_constraints.py
@@ -660,7 +660,7 @@ class TestTimeConstraints:
 
 
 class TestBytesConstraints:
-    @pytest.mark.parametrize("typ", [bytes, bytearray])
+    @pytest.mark.parametrize("typ", [bytes, bytearray, memoryview])
     def test_min_length(self, proto, typ):
         class Ex(msgspec.Struct):
             x: Annotated[typ, Meta(min_length=2)]
@@ -668,14 +668,14 @@ class TestBytesConstraints:
         dec = proto.Decoder(Ex)
 
         for x in [b"xx", b"xxx"]:
-            assert dec.decode(proto.encode(Ex(x))).x == x
+            assert bytes(dec.decode(proto.encode(Ex(x))).x) == x
 
         err_msg = r"Expected `bytes` of length >= 2 - at `\$.x`"
         for x in [b"", b"x"]:
             with pytest.raises(msgspec.ValidationError, match=err_msg):
                 dec.decode(proto.encode(Ex(x)))
 
-    @pytest.mark.parametrize("typ", [bytes, bytearray])
+    @pytest.mark.parametrize("typ", [bytes, bytearray, memoryview])
     def test_max_length(self, proto, typ):
         class Ex(msgspec.Struct):
             x: Annotated[typ, Meta(max_length=2)]
@@ -683,13 +683,13 @@ class TestBytesConstraints:
         dec = proto.Decoder(Ex)
 
         for x in [b"", b"xx"]:
-            assert dec.decode(proto.encode(Ex(x))).x == x
+            assert bytes(dec.decode(proto.encode(Ex(x))).x) == x
 
         err_msg = r"Expected `bytes` of length <= 2 - at `\$.x`"
         with pytest.raises(msgspec.ValidationError, match=err_msg):
             dec.decode(proto.encode(Ex(b"xxx")))
 
-    @pytest.mark.parametrize("typ", [bytes, bytearray])
+    @pytest.mark.parametrize("typ", [bytes, bytearray, memoryview])
     def test_combinations(self, proto, typ):
         class Ex(msgspec.Struct):
             x: Annotated[typ, Meta(min_length=2, max_length=4)]
@@ -697,7 +697,7 @@ class TestBytesConstraints:
         dec = proto.Decoder(Ex)
 
         for x in [b"xx", b"xxx", b"xxxx"]:
-            assert dec.decode(proto.encode(Ex(x))).x == x
+            assert bytes(dec.decode(proto.encode(Ex(x))).x) == x
 
         for x in [b"x", b"xxxxx"]:
             with pytest.raises(msgspec.ValidationError):
diff --git a/tests/test_convert.py b/tests/test_convert.py
index 5f9fe2b..1ccb19f 100644
--- a/tests/test_convert.py
+++ b/tests/test_convert.py
@@ -539,31 +539,31 @@ class TestStr:
 
 
 class TestBinary:
-    @pytest.mark.parametrize("out_type", [bytes, bytearray])
+    @pytest.mark.parametrize("out_type", [bytes, bytearray, memoryview])
     def test_binary_wrong_type(self, out_type):
         with pytest.raises(ValidationError, match="Expected `bytes`, got `int`"):
             convert(1, out_type)
 
-    @pytest.mark.parametrize("in_type", [bytes, bytearray])
-    @pytest.mark.parametrize("out_type", [bytes, bytearray])
+    @pytest.mark.parametrize("in_type", [bytes, bytearray, memoryview])
+    @pytest.mark.parametrize("out_type", [bytes, bytearray, memoryview])
     def test_binary_builtin(self, in_type, out_type):
         res = convert(in_type(b"test"), out_type)
         assert res == b"test"
         assert isinstance(res, out_type)
 
-    @pytest.mark.parametrize("out_type", [bytes, bytearray])
+    @pytest.mark.parametrize("out_type", [bytes, bytearray, memoryview])
     def test_binary_base64(self, out_type):
         res = convert("AQI=", out_type)
         assert res == b"\x01\x02"
         assert isinstance(res, out_type)
 
-    @pytest.mark.parametrize("out_type", [bytes, bytearray])
+    @pytest.mark.parametrize("out_type", [bytes, bytearray, memoryview])
     def test_binary_base64_disabled(self, out_type):
         with pytest.raises(ValidationError, match="Expected `bytes`, got `str`"):
-            convert("AQI=", out_type, builtin_types=(bytes, bytearray))
+            convert("AQI=", out_type, builtin_types=(bytes, bytearray, memoryview))
 
-    @pytest.mark.parametrize("in_type", [bytes, bytearray, str])
-    @pytest.mark.parametrize("out_type", [bytes, bytearray])
+    @pytest.mark.parametrize("in_type", [bytes, bytearray, memoryview, str])
+    @pytest.mark.parametrize("out_type", [bytes, bytearray, memoryview])
     @uses_annotated
     def test_binary_constraints(self, in_type, out_type):
         class Ex(Struct):
@@ -590,11 +590,13 @@ class TestBinary:
 
         msg = MyBytes(b"abc")
 
-        for typ in [bytes, bytearray]:
+        for typ in [bytes, bytearray, memoryview]:
             sol = convert(msg, typ)
             assert type(sol) is typ
             assert sol == b"abc"
 
+        del sol
+
         assert sys.getrefcount(msg) == 2  # msg + 1
         sol = convert(msg, MyBytes)
         assert sol is msg
@@ -735,7 +737,7 @@ class TestUUID:
         res = convert(str(sol), uuid.UUID)
         assert res == sol
 
-    @pytest.mark.parametrize("input_type", [bytes, bytearray])
+    @pytest.mark.parametrize("input_type", [bytes, bytearray, memoryview])
     def test_uuid_bytes(self, input_type):
         sol = uuid.uuid4()
         msg = input_type(sol.bytes)
diff --git a/tests/test_inspect.py b/tests/test_inspect.py
index f1a77ca..291fd99 100644
--- a/tests/test_inspect.py
+++ b/tests/test_inspect.py
@@ -131,7 +131,12 @@ def test_string(kw):
     [{}, dict(min_length=0), dict(max_length=3)],
 )
 @pytest.mark.parametrize(
-    "typ, info_type", [(bytes, mi.BytesType), (bytearray, mi.ByteArrayType)]
+    "typ, info_type",
+    [
+        (bytes, mi.BytesType),
+        (bytearray, mi.ByteArrayType),
+        (memoryview, mi.MemoryViewType),
+    ],
 )
 def test_binary(kw, typ, info_type):
     if kw:
diff --git a/tests/test_json.py b/tests/test_json.py
index 38d08d7..385a9b9 100644
--- a/tests/test_json.py
+++ b/tests/test_json.py
@@ -739,12 +739,12 @@ class TestBinary:
     @pytest.mark.parametrize(
         "x", [b"", b"a", b"ab", b"abc", b"abcd", b"abcde", b"abcdef", b"\x00\xff"]
     )
-    @pytest.mark.parametrize("type", [bytes, bytearray])
+    @pytest.mark.parametrize("type", [bytes, bytearray, memoryview])
     def test_decode_binary(self, x, type):
         s = b'"' + base64.b64encode(x) + b'"'
-        x2 = msgspec.json.decode(s, type=type)
-        assert x == x2
-        assert isinstance(x2, type)
+        res = msgspec.json.decode(s, type=type)
+        assert res == bytes(x)
+        assert isinstance(res, type)
 
     @pytest.mark.parametrize("n", [1023, 1024, 1025])
     def test_roundtrip_random(self, n, rand):
diff --git a/tests/test_msgpack.py b/tests/test_msgpack.py
index e6fa335..f4467cb 100644
--- a/tests/test_msgpack.py
+++ b/tests/test_msgpack.py
@@ -457,15 +457,6 @@ class TestEncoderMisc:
         out2 = enc.encode([1, 2, 3])
         assert out1 == out2
 
-    @pytest.mark.parametrize("size", SIZES)
-    def test_encode_memoryview(self, size):
-        """We don't support memoryview as a decode type, just check we can
-        encode them fine"""
-        buf = bytearray(size)
-        msg = msgspec.msgpack.encode(memoryview(buf))
-        res = msgspec.msgpack.decode(msg)
-        assert buf == res
-
     @pytest.mark.parametrize(
         "dt, dt_str",
         [
@@ -670,28 +661,35 @@ class TestTypedDecoder:
         self.check_unexpected_type(str, 1, "Expected `str`")
 
     @pytest.mark.parametrize("size", SIZES)
-    def test_bytes(self, size):
+    @pytest.mark.parametrize("typ", [bytes, bytearray, memoryview])
+    def test_binary(self, size, typ):
         enc = msgspec.msgpack.Encoder()
-        dec = msgspec.msgpack.Decoder(bytes)
-        x = b"a" * size
-        res = dec.decode(enc.encode(x))
-        assert isinstance(res, bytes)
-        assert res == x
-
-    def test_bytes_unexpected_type(self):
-        self.check_unexpected_type(bytes, 1, "Expected `bytes`")
-
-    @pytest.mark.parametrize("size", SIZES)
-    def test_bytearray(self, size):
-        enc = msgspec.msgpack.Encoder()
-        dec = msgspec.msgpack.Decoder(bytearray)
-        x = bytearray(size)
-        res = dec.decode(enc.encode(x))
-        assert isinstance(res, bytearray)
-        assert res == x
-
-    def test_bytearray_unexpected_type(self):
-        self.check_unexpected_type(bytearray, 1, "Expected `bytes`")
+        dec = msgspec.msgpack.Decoder(typ)
+        sol = b"a" * size
+        res = dec.decode(enc.encode(typ(sol)))
+        assert isinstance(res, typ)
+        assert bytes(res) == sol
+
+    @pytest.mark.parametrize("typ", [bytes, bytearray, memoryview])
+    def test_binary_unexpected_type(self, typ):
+        self.check_unexpected_type(typ, 1, "Expected `bytes`")
+
+    @pytest.mark.parametrize("input_type", [bytes, bytearray, memoryview])
+    def test_decode_memoryview_zerocopy(self, input_type):
+        msg = msgspec.msgpack.encode(b"abcde")
+        ref = msg if input_type is memoryview else None
+        msg = input_type(msg)
+        res = msgspec.msgpack.decode(msg, type=memoryview)
+        assert isinstance(res, memoryview)
+        assert bytes(res) == b"abcde"
+        if input_type is memoryview:
+            assert sys.getrefcount(ref) == 3
+            del msg
+            assert sys.getrefcount(ref) == 3
+            del res
+            assert sys.getrefcount(ref) == 2
+        elif input_type is bytes:
+            assert sys.getrefcount(msg) == 3
 
     def test_datetime_aware_ext(self):
         dec = msgspec.msgpack.Decoder(datetime.datetime)
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 40b83f7..a4ffb9f 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -110,7 +110,7 @@ def test_string():
     assert msgspec.json.schema(str) == {"type": "string"}
 
 
-@pytest.mark.parametrize("typ", [bytes, bytearray])
+@pytest.mark.parametrize("typ", [bytes, bytearray, memoryview])
 def test_binary(typ):
     assert msgspec.json.schema(typ) == {
         "type": "string",
@@ -1108,7 +1108,7 @@ def test_dict_key_metadata(field, val, constraint):
     }
 
 
-@pytest.mark.parametrize("typ", [bytes, bytearray])
+@pytest.mark.parametrize("typ", [bytes, bytearray, memoryview])
 @pytest.mark.parametrize(
     "field, n, constraint",
     [("min_length", 2, "minLength"), ("max_length", 7, "maxLength")],