Repository: jcrist/msgspec
Pull request #222 (2022-12-04 08:29:01)

Add support for encoding/decoding UUIDs
This adds support for encoding/decoding UUIDs as strings in the format described by RFC4122.

The encoding and decoding are done as efficiently as possible - on my machine UUIDs

- encode ~2x faster than orjson
- encode ~70x faster than pydantic
- decode ~10x faster than pydantic

Benchmark:

```python
In [1]: import msgspec, orjson, pydantic, uuid

In [2]: class Struct(msgspec.Struct):
   ...:     data: list[uuid.UUID]
   ...: 

In [3]: class Model(pydantic.BaseModel):
   ...:     data: list[uuid.UUID]
   ...: 

In [4]: data = [uuid.uuid4() for _ in range(5000)]

In [5]: struct = Struct(data=data)

In [6]: model = Model(data=data)

In [7]: enc = msgspec.json.Encoder()

In [8]: dec = msgspec.json.Decoder(Struct)

In [9]: msg = enc.encode(struct)

In [10]: %timeit enc.encode(struct)  # msgspec encode time
252 µs ± 4.32 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

In [11]: %timeit orjson.dumps(data)  # orjson encode time
537 µs ± 1.99 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

In [12]: %timeit model.json()  # pydantic encode time
18 ms ± 37.5 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

In [13]: %timeit dec.decode(msg)  # msgspec decode time
1.07 ms ± 4.45 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

In [14]: %timeit Model.parse_raw(msg)  # pydantic decode time
11.3 ms ± 115 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
```


Patch:
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index f48686e..f468f3d 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -114,6 +114,7 @@ Most combinations of the following types are supported (see
 - `frozenset` / `typing.FrozenSet`
 - `datetime.datetime`
 - `datetime.date`
+- `uuid.UUID`
 - `typing.Any`
 - `typing.Optional`
 - `typing.Union`
@@ -371,11 +372,36 @@ timezones are normalized to UTC.
     >>> msgspec.json.decode(msg, type=datetime.date)
     datetime.date(2021, 4, 2)
 
-    >>> msgspec.json.decode(b'"oops not a date"', type=datetime.datetime)
+    >>> msgspec.json.decode(b'"oops not a date"', type=datetime.date)
     Traceback (most recent call last):
       File "<stdin>", line 1, in <module>
     msgspec.ValidationError: Invalid RFC3339 encoded date
 
+``uuid``
+~~~~~~~~
+
+`uuid.UUID` values are serialized as RFC4122_ encoded strings.
+
+.. code-block:: python
+
+    >>> import uuid
+
+    >>> u = uuid.UUID("c4524ac0-e81e-4aa8-a595-0aec605a659a")
+
+    >>> msg = msgspec.json.encode(u)
+
+    >>> msg
+    b'"c4524ac0-e81e-4aa8-a595-0aec605a659a"'
+
+    >>> msgspec.json.decode(msg, type=uuid.UUID)
+    UUID('c4524ac0-e81e-4aa8-a595-0aec605a659a')
+
+    >>> msgspec.json.decode(b'"oops not a uuid"', type=uuid.UUID)
+    Traceback (most recent call last):
+        File "<stdin>", line 1, in <module>
+    msgspec.ValidationError: Invalid UUID
+
+
 ``list`` / ``tuple`` / ``set`` / ``frozenset``
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -993,6 +1019,7 @@ efficiently skipped without decoding.
 .. _pydantic: https://pydantic-docs.helpmanual.io/
 .. _RFC8259: https://datatracker.ietf.org/doc/html/rfc8259
 .. _RFC3339: https://datatracker.ietf.org/doc/html/rfc3339
+.. _RFC4122: https://datatracker.ietf.org/doc/html/rfc4122
 .. _timestamp extension: https://github.com/msgpack/msgpack/blob/master/spec.md#timestamp-extension-type
 .. _dataclasses: https://docs.python.org/3/library/dataclasses.html
 .. _attrs: https://www.attrs.org/en/stable/index.html
diff --git a/msgspec/_core.c b/msgspec/_core.c
index f9bf9b8..a6c1c89 100644
--- a/msgspec/_core.c
+++ b/msgspec/_core.c
@@ -139,6 +139,16 @@ fast_long_extract_parts(PyObject *vv, bool *neg, uint64_t *scale) {
 #define MS_SET_FIRST_SLOT(obj, val) \
     MS_GET_FIRST_SLOT(obj) = (val)
 
+
+/*************************************************************************
+ * Lookup Tables                                                         *
+ *************************************************************************/
+
+static const char hex_encode_table[] = "0123456789abcdef";
+
+static const char base64_encode_table[] =
+"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
 /*************************************************************************
  * GC Utilities                                                          *
  *************************************************************************/
@@ -312,6 +322,10 @@ typedef struct {
     PyObject *str__field_defaults;
     PyObject *str___dataclass_fields__;
     PyObject *str___post_init__;
+    PyObject *str_int;
+    PyObject *str_is_safe;
+    PyObject *UUIDType;
+    PyObject *uuid_safeuuid_unknown;
     PyObject *typing_list;
     PyObject *typing_set;
     PyObject *typing_frozenset;
@@ -1706,26 +1720,27 @@ static PyTypeObject Meta_Type = {
 #define MS_TYPE_BYTEARRAY           (1ull << 7)
 #define MS_TYPE_DATETIME            (1ull << 8)
 #define MS_TYPE_DATE                (1ull << 9)
-#define MS_TYPE_EXT                 (1ull << 10)
-#define MS_TYPE_STRUCT              (1ull << 11)
-#define MS_TYPE_STRUCT_ARRAY        (1ull << 12)
-#define MS_TYPE_STRUCT_UNION        (1ull << 13)
-#define MS_TYPE_STRUCT_ARRAY_UNION  (1ull << 14)
-#define MS_TYPE_ENUM                (1ull << 15)
-#define MS_TYPE_INTENUM             (1ull << 16)
-#define MS_TYPE_CUSTOM              (1ull << 17)
-#define MS_TYPE_CUSTOM_GENERIC      (1ull << 18)
-#define MS_TYPE_DICT                ((1ull << 19) | (1ull << 20))
-#define MS_TYPE_LIST                (1ull << 21)
-#define MS_TYPE_SET                 (1ull << 22)
-#define MS_TYPE_FROZENSET           (1ull << 23)
-#define MS_TYPE_VARTUPLE            (1ull << 24)
-#define MS_TYPE_FIXTUPLE            (1ull << 25)
-#define MS_TYPE_INTLITERAL          (1ull << 26)
-#define MS_TYPE_STRLITERAL          (1ull << 27)
-#define MS_TYPE_TYPEDDICT           (1ull << 28)
-#define MS_TYPE_DATACLASS           (1ull << 29)
-#define MS_TYPE_NAMEDTUPLE          (1ull << 30)
+#define MS_TYPE_UUID                (1ull << 10)
+#define MS_TYPE_EXT                 (1ull << 11)
+#define MS_TYPE_STRUCT              (1ull << 12)
+#define MS_TYPE_STRUCT_ARRAY        (1ull << 13)
+#define MS_TYPE_STRUCT_UNION        (1ull << 14)
+#define MS_TYPE_STRUCT_ARRAY_UNION  (1ull << 15)
+#define MS_TYPE_ENUM                (1ull << 16)
+#define MS_TYPE_INTENUM             (1ull << 17)
+#define MS_TYPE_CUSTOM              (1ull << 18)
+#define MS_TYPE_CUSTOM_GENERIC      (1ull << 19)
+#define MS_TYPE_DICT                ((1ull << 20) | (1ull << 21))
+#define MS_TYPE_LIST                (1ull << 22)
+#define MS_TYPE_SET                 (1ull << 23)
+#define MS_TYPE_FROZENSET           (1ull << 24)
+#define MS_TYPE_VARTUPLE            (1ull << 25)
+#define MS_TYPE_FIXTUPLE            (1ull << 26)
+#define MS_TYPE_INTLITERAL          (1ull << 27)
+#define MS_TYPE_STRLITERAL          (1ull << 28)
+#define MS_TYPE_TYPEDDICT           (1ull << 29)
+#define MS_TYPE_DATACLASS           (1ull << 30)
+#define MS_TYPE_NAMEDTUPLE          (1ull << 31)
 /* Constraints */
 #define MS_CONSTR_INT_MIN           (1ull << 32)
 #define MS_CONSTR_INT_MAX           (1ull << 33)
@@ -2285,6 +2300,9 @@ typenode_simple_repr(TypeNode *self) {
     if (self->types & MS_TYPE_DATE) {
         if (!strbuilder_extend_literal(&builder, "date")) return NULL;
     }
+    if (self->types & MS_TYPE_UUID) {
+        if (!strbuilder_extend_literal(&builder, "uuid")) return NULL;
+    }
     if (self->types & MS_TYPE_EXT) {
         if (!strbuilder_extend_literal(&builder, "ext")) return NULL;
     }
@@ -2982,14 +3000,16 @@ typenode_collect_check_invariants(
     /* Ensure str-like types don't conflict */
     if (ms_popcount(
             state->types & (
-                MS_TYPE_STR | MS_TYPE_STRLITERAL | MS_TYPE_ENUM | MS_TYPE_DATE
+                MS_TYPE_STR | MS_TYPE_STRLITERAL | MS_TYPE_ENUM |
+                MS_TYPE_DATE | MS_TYPE_UUID
             )
         ) > 1
     ) {
         PyErr_Format(
             PyExc_TypeError,
             "Type unions may not contain more than one str-like type (`str`, "
-            "`Enum`, `Literal[str values]`, `date`) - type `%R` is not supported",
+            "`Enum`, `Literal[str values]`, `date`, `uuid`) - type `%R` is not "
+            "supported",
             state->context
         );
         return -1;
@@ -3001,7 +3021,7 @@ typenode_collect_check_invariants(
             state->types & (
                 MS_TYPE_STR | MS_TYPE_STRLITERAL | MS_TYPE_ENUM |
                 MS_TYPE_BYTES | MS_TYPE_BYTEARRAY |
-                MS_TYPE_DATETIME | MS_TYPE_DATE
+                MS_TYPE_DATETIME | MS_TYPE_DATE | MS_TYPE_UUID
             )
         ) > 1
     ) {
@@ -3010,7 +3030,7 @@ typenode_collect_check_invariants(
                 PyExc_TypeError,
                 "JSON type unions may not contain more than one str-like type "
                 "(`str`, `Enum`, `Literal[str values]`, `bytes`, `bytearray`, "
-                "`datetime`, `date`) - type `%R` is not supported",
+                "`datetime`, `date`, `uuid`) - type `%R` is not supported",
                 state->context
             );
             return -1;
@@ -3673,6 +3693,10 @@ typenode_collect_type_full(
         state->types |= MS_TYPE_DATE;
         return 0;
     }
+    else if (obj == state->mod->UUIDType) {
+        state->types |= MS_TYPE_UUID;
+        return 0;
+    }
     else if (obj == (PyObject *)(&Ext_Type)) {
         state->types |= MS_TYPE_EXT;
         return 0;
@@ -8031,6 +8055,102 @@ invalid:
     return ms_error_with_path("Invalid RFC3339 encoded datetime%U", path);
 }
 
+/*************************************************************************
+ * UUID Utilities                                                        *
+ *************************************************************************/
+
+static int
+ms_encode_uuid(EncoderState *self, PyObject *obj, char *out) {
+    int status = -1;
+    PyObject *int128 = PyObject_GetAttr(obj, self->mod->str_int);
+    if (int128 == NULL) return -1;
+    if (MS_UNLIKELY(!PyLong_CheckExact(int128))) {
+        PyErr_SetString(PyExc_TypeError, "uuid.int must be an int");
+        return -1;
+    }
+    unsigned char scratch[16];
+    unsigned char *buf = scratch;
+
+    if (_PyLong_AsByteArray((PyLongObject *)int128, scratch, 16, 0, 0) < 0) {
+        goto cleanup;
+    }
+
+    for (int i = 0; i < 4; i++) {
+        unsigned char c = *buf++;
+        *out++ = hex_encode_table[c >> 4];
+        *out++ = hex_encode_table[c & 0xF];
+    }
+    *out++ = '-';
+    for (int j = 0; j < 3; j++) {
+        for (int i = 0; i < 2; i++) {
+            unsigned char c = *buf++;
+            *out++ = hex_encode_table[c >> 4];
+            *out++ = hex_encode_table[c & 0xF];
+        }
+        *out++ = '-';
+    }
+    for (int i = 0; i < 6; i++) {
+        unsigned char c = *buf++;
+        *out++ = hex_encode_table[c >> 4];
+        *out++ = hex_encode_table[c & 0xF];
+    }
+    status = 0;
+cleanup:
+    Py_DECREF(int128);
+    return status;
+}
+
+
+static PyObject *
+ms_decode_uuid(const char *buf, Py_ssize_t size, PathNode *path) {
+    PyObject *out = NULL;
+    unsigned char scratch[16];
+    unsigned char *decoded = scratch;
+    int segments[] = {4, 2, 2, 2, 6};
+
+    /* A valid uuid is 36 characters in length */
+    if (size != 36) goto invalid;
+
+    for (int i = 0; i < 5; i++) {
+        for (int j = 0; j < segments[i]; j++) {
+            char hi = *buf++;
+            if (hi >= '0' && hi <= '9') { hi -= '0'; }
+            else if (hi >= 'a' && hi <= 'f') { hi = hi - 'a' + 10; }
+            else if (hi >= 'A' && hi <= 'F') { hi = hi - 'A' + 10; }
+            else { goto invalid; }
+
+            char lo = *buf++;
+            if (lo >= '0' && lo <= '9') { lo -= '0'; }
+            else if (lo >= 'a' && lo <= 'f') { lo = lo - 'a' + 10; }
+            else if (lo >= 'A' && lo <= 'F') { lo = lo - 'A' + 10; }
+            else { goto invalid; }
+
+            *decoded++ = ((unsigned char)hi << 4) + (unsigned char)lo;
+        }
+        if (i < 4 && *buf++ != '-') goto invalid;
+    }
+    PyObject *int128 = _PyLong_FromByteArray(scratch, 16, 0, 0);
+    if (int128 == NULL) return NULL;
+
+    MsgspecState *mod = msgspec_get_global_state();
+    PyTypeObject *uuid_type = (PyTypeObject *)(mod->UUIDType);
+    out = uuid_type->tp_alloc(uuid_type, 0);
+    if (out == NULL) goto error;
+    /* UUID objects are immutable, use GenericSetAttr instead of SetAttr */
+    if (PyObject_GenericSetAttr(out, mod->str_int, int128) < 0) goto error;
+    if (PyObject_GenericSetAttr(out, mod->str_is_safe, mod->uuid_safeuuid_unknown) < 0) goto error;
+
+    Py_DECREF(int128);
+    return out;
+
+error:
+    Py_DECREF(int128);
+    Py_XDECREF(out);
+    return NULL;
+
+invalid:
+    return ms_error_with_path("Invalid UUID%U", path);
+}
 
 /*************************************************************************
  * MessagePack Encoder                                                   *
@@ -8744,6 +8864,14 @@ mpack_encode_enum(EncoderState *self, PyObject *obj)
     return status;
 }
 
+static int
+mpack_encode_uuid(EncoderState *self, PyObject *obj)
+{
+    char buf[36];
+    if (ms_encode_uuid(self, obj, buf) < 0) return -1;
+    return mpack_encode_cstr(self, buf, 36);
+}
+
 static int
 mpack_encode_date(EncoderState *self, PyObject *obj)
 {
@@ -8874,6 +9002,9 @@ mpack_encode(EncoderState *self, PyObject *obj)
     else if (Py_TYPE(type) == self->mod->EnumMetaType) {
         return mpack_encode_enum(self, obj);
     }
+    else if (type == (PyTypeObject *)(self->mod->UUIDType)) {
+        return mpack_encode_uuid(self, obj);
+    }
     else if (PyDict_Contains(type->tp_dict, self->mod->str___dataclass_fields__)) {
         return mpack_encode_object(self, obj);
     }
@@ -9101,9 +9232,8 @@ json_write_str_fragment(
     /* Write the escaped character */
     char escaped[6] = {'\\', escape, '0', '0'};
     if (MS_UNLIKELY(escape == 'u')) {
-        static const char* const hex = "0123456789abcdef";
-        escaped[4] = hex[c >> 4];
-        escaped[5] = hex[c & 0xF];
+        escaped[4] = hex_encode_table[c >> 4];
+        escaped[5] = hex_encode_table[c & 0xF];
         if (MS_UNLIKELY(ms_write(self, escaped, 6) < 0)) return -1;
     }
     else {
@@ -9152,9 +9282,6 @@ json_encode_str(EncoderState *self, PyObject *obj) {
     return ms_write(self, "\"", 1);
 }
 
-static const char base64_encode_table[] =
-"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
-
 static int
 json_encode_bin(EncoderState *self, const char* buf, Py_ssize_t len) {
     char *out;
@@ -9268,6 +9395,16 @@ json_encode_enum(EncoderState *self, PyObject *obj)
     return status;
 }
 
+static int
+json_encode_uuid(EncoderState *self, PyObject *obj)
+{
+    char buf[38];
+    buf[0] = '"';
+    buf[37] = '"';
+    if (ms_encode_uuid(self, obj, buf + 1) < 0) return -1;
+    return ms_write(self, buf, 38);
+}
+
 static int
 json_encode_date(EncoderState *self, PyObject *obj)
 {
@@ -9747,6 +9884,9 @@ json_encode(EncoderState *self, PyObject *obj)
     else if (Py_TYPE(type) == self->mod->EnumMetaType) {
         return json_encode_enum(self, obj);
     }
+    else if (type == (PyTypeObject *)(self->mod->UUIDType)) {
+        return json_encode_uuid(self, obj);
+    }
     else if (PyDict_Contains(type->tp_dict, self->mod->str___dataclass_fields__)) {
         return json_encode_object(self, obj);
     }
@@ -10421,7 +10561,7 @@ mpack_decode_str(DecoderState *self, Py_ssize_t size, TypeNode *type, PathNode *
     if (MS_LIKELY(
             type->types & (
                 MS_TYPE_ANY | MS_TYPE_STR | MS_TYPE_ENUM | MS_TYPE_STRLITERAL |
-                MS_TYPE_DATE
+                MS_TYPE_DATE | MS_TYPE_UUID
             )
         )
     ) {
@@ -10433,6 +10573,9 @@ mpack_decode_str(DecoderState *self, Py_ssize_t size, TypeNode *type, PathNode *
         if (MS_UNLIKELY(type->types & MS_TYPE_DATE)) {
             return ms_decode_date(s, size, path);
         }
+        if (MS_UNLIKELY(type->types & MS_TYPE_UUID)) {
+            return ms_decode_uuid(s, size, path);
+        }
         return ms_check_str_constraints(
             PyUnicode_DecodeUTF8(s, size, NULL), type, path
         );
@@ -11047,9 +11190,8 @@ mpack_decode_dataclass(
 
     DataclassInfo *info = TypeNode_get_dataclass_info(type);
 
-    Py_ssize_t nfields = Py_SIZE(info);
     PyTypeObject *dataclass_type = (PyTypeObject *)(info->class);
-    PyObject *out = dataclass_type->tp_alloc(dataclass_type, nfields);
+    PyObject *out = dataclass_type->tp_alloc(dataclass_type, 0);
     if (out == NULL) goto error;
 
     Py_ssize_t pos = 0;
@@ -12459,7 +12601,7 @@ json_decode_string(JSONDecoderState *self, TypeNode *type, PathNode *path) {
             type->types & (
                 MS_TYPE_ANY | MS_TYPE_STR | MS_TYPE_ENUM | MS_TYPE_STRLITERAL |
                 MS_TYPE_BYTES | MS_TYPE_BYTEARRAY |
-                MS_TYPE_DATETIME | MS_TYPE_DATE
+                MS_TYPE_DATETIME | MS_TYPE_DATE | MS_TYPE_UUID
             )
         )
     ) {
@@ -12484,6 +12626,9 @@ json_decode_string(JSONDecoderState *self, TypeNode *type, PathNode *path) {
         else if (MS_UNLIKELY(type->types & MS_TYPE_DATE)) {
             return ms_decode_date(view, size, path);
         }
+        else if (MS_UNLIKELY(type->types & MS_TYPE_UUID)) {
+            return ms_decode_uuid(view, size, path);
+        }
         else if (MS_UNLIKELY(type->types & (MS_TYPE_BYTES | MS_TYPE_BYTEARRAY))) {
             return json_decode_binary(self, view, size, type, path);
         }
@@ -13451,7 +13596,7 @@ json_decode_dataclass(
     if (Py_EnterRecursiveCall(" while deserializing an object")) return NULL;
 
     PyTypeObject *dataclass_type = (PyTypeObject *)(info->class);
-    out = dataclass_type->tp_alloc(dataclass_type, Py_SIZE(info));
+    out = dataclass_type->tp_alloc(dataclass_type, 0);
     if (out == NULL) goto error;
 
     self->input_pos++; /* Skip '{' */
@@ -14538,6 +14683,10 @@ msgspec_clear(PyObject *m)
     Py_CLEAR(st->str__field_defaults);
     Py_CLEAR(st->str___dataclass_fields__);
     Py_CLEAR(st->str___post_init__);
+    Py_CLEAR(st->str_int);
+    Py_CLEAR(st->str_is_safe);
+    Py_CLEAR(st->UUIDType);
+    Py_CLEAR(st->uuid_safeuuid_unknown);
     Py_CLEAR(st->typing_dict);
     Py_CLEAR(st->typing_list);
     Py_CLEAR(st->typing_set);
@@ -14847,6 +14996,17 @@ PyInit__core(void)
     Py_DECREF(temp_obj);
     if (st->astimezone == NULL) return NULL;
 
+    /* Get uuid.SafeUUID.unknown */
+    temp_module = PyImport_ImportModule("uuid");
+    if (temp_module == NULL) return NULL;
+    st->UUIDType = PyObject_GetAttrString(temp_module, "UUID");
+    if (st->UUIDType == NULL) return NULL;
+    temp_obj = PyObject_GetAttrString(temp_module, "SafeUUID");
+    if (temp_obj == NULL) return NULL;
+    st->uuid_safeuuid_unknown = PyObject_GetAttrString(temp_obj, "unknown");
+    Py_DECREF(temp_obj);
+    if (st->uuid_safeuuid_unknown == NULL) return NULL;
+
     /* Get the re.compile function */
     temp_module = PyImport_ImportModule("re");
     if (temp_module == NULL) return NULL;
@@ -14882,6 +15042,8 @@ PyInit__core(void)
     CACHED_STRING(str__field_defaults, "_field_defaults");
     CACHED_STRING(str___dataclass_fields__, "__dataclass_fields__");
     CACHED_STRING(str___post_init__, "__post_init__");
+    CACHED_STRING(str_int, "int");
+    CACHED_STRING(str_is_safe, "is_safe");
 
     return m;
 }
diff --git a/msgspec/_utils.py b/msgspec/_utils.py
index f8da9de..1d66b5c 100644
--- a/msgspec/_utils.py
+++ b/msgspec/_utils.py
@@ -2,6 +2,7 @@ import datetime
 import enum
 import re
 import sys
+import uuid
 from typing import Any, Union, Literal, List, Tuple, Dict, Set, FrozenSet
 
 if sys.version_info >= (3, 9):
@@ -434,6 +435,9 @@ class SchemaBuilder:
         elif t is datetime.date:
             schema["type"] = "string"
             schema["format"] = "date"
+        elif t is uuid.UUID:
+            schema["type"] = "string"
+            schema["format"] = "uuid"
         elif t in (list, set, frozenset):
             schema["type"] = "array"
             if args:


Test patch:
diff --git a/tests/test_common.py b/tests/test_common.py
index 6ab44cc..4c17e05 100644
--- a/tests/test_common.py
+++ b/tests/test_common.py
@@ -4,6 +4,7 @@ import datetime
 import enum
 import gc
 import sys
+import uuid
 import weakref
 from collections import namedtuple
 from dataclasses import dataclass, field
@@ -1988,3 +1989,65 @@ class TestDate:
         msg = proto.encode(s)
         with pytest.raises(msgspec.ValidationError, match="Invalid RFC3339"):
             proto.decode(msg, type=datetime.date)
+
+
+class TestUUID:
+    def test_encode_uuid(self, proto):
+        u = uuid.uuid4()
+        res = proto.encode(u)
+        sol = proto.encode(str(u))
+        assert res == sol
+
+    def test_encode_uuid_malformed_internals(self, proto):
+        """Ensure that if some other code mutates the uuid object, we error
+        nicely rather than segfaulting"""
+        u = uuid.uuid4()
+        object.__delattr__(u, "int")
+
+        with pytest.raises(AttributeError):
+            proto.encode(u)
+
+        u = uuid.uuid4()
+        object.__setattr__(u, "int", "oops")
+
+        with pytest.raises(TypeError):
+            proto.encode(u)
+
+    @pytest.mark.parametrize("upper", [False, True])
+    def test_decode_uuid(self, proto, upper):
+        u = uuid.uuid4()
+        s = str(u).upper() if upper else str(u)
+        msg = proto.encode(s)
+        res = proto.decode(msg, type=uuid.UUID)
+        assert res == u
+        assert res.is_safe == u.is_safe
+
+    @pytest.mark.parametrize(
+        "uuid_str",
+        [
+            # Truncated segments
+            "1234567-1234-1234-1234-1234567890abc",
+            "12345678-123-1234-1234-1234567890abc",
+            "12345678-1234-123-1234-1234567890abc",
+            "12345678-1234-1234-123-1234567890abc",
+            "12345678-1234-1234-1234-1234567890a-",
+            # Invalid character
+            "1234567x-1234-1234-1234-1234567890ab",
+            "12345678-123x-1234-1234-1234567890ab",
+            "12345678-1234-123x-1234-1234567890ab",
+            "12345678-1234-1234-123x-1234567890ab",
+            "12345678-1234-1234-1234-1234567890ax",
+            # Invalid dash
+            "12345678.1234-1234-1234-1234567890ab",
+            "12345678-1234.1234-1234-1234567890ab",
+            "12345678-1234-1234.1234-1234567890ab",
+            "12345678-1234-1234-1234.1234567890ab",
+            # Trailing data
+            "12345678-1234-1234-1234-1234567890ab-",
+            "12345678-1234-1234-1234-1234567890abc",
+        ],
+    )
+    def test_decode_uuid_malformed(self, proto, uuid_str):
+        msg = proto.encode(uuid_str)
+        with pytest.raises(msgspec.ValidationError, match="Invalid UUID"):
+            proto.decode(msg, type=uuid.UUID)
diff --git a/tests/test_schema.py b/tests/test_schema.py
index ee7287e..f3fa12e 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -1,5 +1,6 @@
 import enum
 import datetime
+import decimal
 import uuid
 from base64 import b64encode
 from copy import deepcopy
@@ -79,13 +80,13 @@ def test_msgpack_ext():
 
 def test_custom():
     with pytest.raises(TypeError, match="Custom types"):
-        assert msgspec.json.schema(uuid.UUID)
+        assert msgspec.json.schema(decimal.Decimal)
 
-    schema = {"type": "string", "pattern": "uuid4"}
+    schema = {"type": "string", "format": "decimal"}
 
     assert (
         msgspec.json.schema(
-            Annotated[uuid.UUID, msgspec.Meta(extra_json_schema=schema)]
+            Annotated[decimal.Decimal, msgspec.Meta(extra_json_schema=schema)]
         )
         == schema
     )
@@ -133,6 +134,13 @@ def test_date():
     }
 
 
+def test_uuid():
+    assert msgspec.json.schema(uuid.UUID) == {
+        "type": "string",
+        "format": "uuid",
+    }
+
+
 @pytest.mark.parametrize(
     "typ", [list, tuple, set, frozenset, List, Tuple, Set, FrozenSet]
 )