Repository: jcrist/msgspec
Pull request #224 (2022-12-05 02:47:26)

Support both timezone-aware and timezone-naive datetime objects
This PR addresses part of #220. It makes the following changes:

- Naive `datetime.datetime` objects are now encoded as ISO8601 compatible strings for all protocols. These are _almost_ RFC3339 compatible, but lack the timezone component.
- A `datetime.datetime` annotation now will decode both timezone-naive and timezone-aware `datetime.datetime` objects.
- A new `tz` constraint is added for configuring whether the annotated value is timezone-aware or timezone-naive (or both). Set to `True` to enforce timezone-aware and `False` to enforce timezone-naive. Defaults to `None`, which accepts both.
- The `msgpack` protocol now will also decode `datetime.datetime` objects from RFC3339/ISO8601 compatible strings. Previously it only accepted datetimes encoded using the msgpack timestamp extension.


Patch:
diff --git a/docs/source/constraints.rst b/docs/source/constraints.rst
index b7d4f97..4d9674a 100644
--- a/docs/source/constraints.rst
+++ b/docs/source/constraints.rst
@@ -132,6 +132,42 @@ These constraints are valid on `str` types:
       File "<stdin>", line 1, in <module>
     msgspec.ValidationError: Expected `str` matching regex '^[a-z0-9_]*$'
 
+.. _datetime-constraints:
+
+Datetime Constraints
+--------------------
+
+These constraints are valid on `datetime.datetime` and `datetime.time` types:
+
+- ``tz``: Whether the annotated type is required to be timezone-aware_. Set to
+  ``True`` to require timezone-aware values, or ``False`` to require
+  timezone-naive values. The default is ``None``, which accepts either
+  timezone-aware or timezone-naive values.
+
+.. code-block:: python
+
+    >>> import msgspec
+
+    >>> from datetime import datetime
+
+    >>> from typing import Annotated
+
+    >>> msgspec.json.decode(
+    ...     b'"2022-04-02T18:18:10"',
+    ...     type=Annotated[datetime, msgspec.Meta(tz=True)]  # require timezone aware
+    ... )
+    Traceback (most recent call last):
+      File "<stdin>", line 1, in <module>
+    msgspec.ValidationError: Expected `datetime` with a timezone component
+
+    >>> msgspec.json.decode(
+    ...     b'"2022-04-02T18:18:10-06:00"',
+    ...     type=Annotated[datetime, msgspec.Meta(tz=False)]  # require timezone naive
+    ... )
+    Traceback (most recent call last):
+      File "<stdin>", line 1, in <module>
+    msgspec.ValidationError: Expected `datetime` with no timezone component
+
 Bytes Constraints
 -----------------
 
@@ -197,3 +233,5 @@ These constraints are valid on `dict` types:
     Traceback (most recent call last):
       File "<stdin>", line 1, in <module>
     msgspec.ValidationError: Expected `object` of length <= 3
+
+.. _timezone-aware: https://docs.python.org/3/library/datetime.html#aware-and-naive-objects
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index f468f3d..69e8b26 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -325,12 +325,22 @@ type in MessagePack.
 ``datetime``
 ~~~~~~~~~~~~
 
-`datetime.datetime` values are serialized as RFC3339_ encoded strings in JSON,
-and the `timestamp extension`_ in MessagePack. Only `timezone-aware
-<https://docs.python.org/3/library/datetime.html#aware-and-naive-objects>`__
-datetime objects are supported by default (timezone-naive datetimes can be
-supported using an ``enc_hook``, see :doc:`extending`). During decoding, all
-timezones are normalized to UTC.
+The encoding used for `datetime.datetime` objects is dependent on whether these
+objects are timezone-aware_ or timezone-naive:
+
+- Timezone-aware datetimes are encoded as RFC3339_ compatible strings in JSON,
+  and the `timestamp extension` in MessagePack. Both protocols support decoding
+  RFC3339_ strings into ``datetime`` objects. During decoding all timezones are
+  normalized to UTC.
+
+- Timezone-naive datetimes are encoded as ISO8601_ compatible strings (*almost*
+  RFC3339_ compatible) in both protocols. The lack of a timezone component
+  means they're not strictly RFC3339_ compatible, but are still ISO8601_
+  compatible.
+
+Note that you can require a `datetime.datetime` object to be timezone-aware or
+timezone-naive by specifying a ``tz`` constraint (see
+:ref:`datetime-constraints` for more information).
 
 .. code-block:: python
 
@@ -338,9 +348,9 @@ timezones are normalized to UTC.
 
     >>> tz = datetime.timezone(datetime.timedelta(hours=-6))
 
-    >>> dt = datetime.datetime(2021, 4, 2, 18, 18, 10, 123, tzinfo=tz)
+    >>> tz_aware = datetime.datetime(2021, 4, 2, 18, 18, 10, 123, tzinfo=tz)
 
-    >>> msg = msgspec.json.encode(dt)
+    >>> msg = msgspec.json.encode(tz_aware)
 
     >>> msg
     b'"2021-04-02T18:18:10.000123-06:00"'
@@ -348,6 +358,16 @@ timezones are normalized to UTC.
     >>> msgspec.json.decode(msg, type=datetime.datetime)
     datetime.datetime(2021, 4, 3, 0, 18, 10, 123, tzinfo=datetime.timezone.utc)
 
+    >>> tz_naive = datetime.datetime(2021, 4, 2, 18, 18, 10, 123)
+
+    >>> msg = msgspec.json.encode(tz_naive)
+
+    >>> msg
+    b'"2021-04-02T18:18:10.000123"'
+
+    >>> msgspec.json.decode(msg, type=datetime.datetime)
+    datetime.datetime(2021, 4, 2, 18, 18, 10, 123)
+
     >>> msgspec.json.decode(b'"oops not a date"', type=datetime.datetime)
     Traceback (most recent call last):
       File "<stdin>", line 1, in <module>
@@ -1020,6 +1040,8 @@ efficiently skipped without decoding.
 .. _RFC8259: https://datatracker.ietf.org/doc/html/rfc8259
 .. _RFC3339: https://datatracker.ietf.org/doc/html/rfc3339
 .. _RFC4122: https://datatracker.ietf.org/doc/html/rfc4122
+.. _ISO8601: https://en.wikipedia.org/wiki/ISO_8601
 .. _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
+.. _timezone-aware: https://docs.python.org/3/library/datetime.html#aware-and-naive-objects
diff --git a/msgspec/__init__.pyi b/msgspec/__init__.pyi
index 189817d..019e2d4 100644
--- a/msgspec/__init__.pyi
+++ b/msgspec/__init__.pyi
@@ -106,6 +106,7 @@ class Meta:
         pattern: Union[str, None] = None,
         min_length: Union[int, None] = None,
         max_length: Union[int, None] = None,
+        tz: Union[bool, None] = None,
         title: Union[str, None] = None,
         description: Union[str, None] = None,
         examples: Union[list, None] = None,
@@ -119,6 +120,7 @@ class Meta:
     pattern: Final[Union[str, None]]
     min_length: Final[Union[int, None]]
     max_length: Final[Union[int, None]]
+    tz: Final[Union[int, None]]
     title: Final[Union[str, None]]
     description: Final[Union[str, None]]
     examples: Final[Union[list, None]]
diff --git a/msgspec/_core.c b/msgspec/_core.c
index a6c1c89..6749b2b 100644
--- a/msgspec/_core.c
+++ b/msgspec/_core.c
@@ -1226,6 +1226,7 @@ typedef struct Meta {
     PyObject *regex;
     PyObject *min_length;
     PyObject *max_length;
+    PyObject *tz;
     PyObject *title;
     PyObject *description;
     PyObject *examples;
@@ -1243,6 +1244,17 @@ ensure_is_string(PyObject *val, const char *param) {
     return false;
 }
 
+static bool
+ensure_is_bool(PyObject *val, const char *param) {
+    if (val == Py_True || val == Py_False) return true;
+    PyErr_Format(
+        PyExc_TypeError,
+        "`%s` must be a bool, got %.200s",
+        param, Py_TYPE(val)->tp_name
+    );
+    return false;
+}
+
 static bool
 ensure_is_nonnegative_integer(PyObject *val, const char *param) {
     if (!PyLong_CheckExact(val)) {
@@ -1293,8 +1305,8 @@ ensure_is_finite_numeric(PyObject *val, const char *param, bool positive) {
 
 PyDoc_STRVAR(Meta__doc__,
 "Meta(*, gt=None, ge=None, lt=None, le=None, multiple_of=None, pattern=None, "
-"min_length=None, max_length=None, title=None, description=None, examples=None, "
-"extra_json_schema=None)\n"
+"min_length=None, max_length=None, tz=None, title=None, description=None, "
+"examples=None, extra_json_schema=None)\n"
 "--\n"
 "\n"
 "Extra metadata and constraints for a type or field.\n"
@@ -1321,6 +1333,11 @@ PyDoc_STRVAR(Meta__doc__,
 "max_length: int, optional\n"
 "    The annotated value must have a length less than or equal to\n"
 "    ``max_length``.\n"
+"tz: bool, optional\n"
+"    Configures the timezone-requirements for annotated ``datetime``/``time``\n"
+"    types. Set to ``True`` to require timezone-aware values, or ``False`` to\n"
+"    require timezone-naive values. The default is ``None``, which accepts\n"
+"    either timezone-aware or timezone-naive values.\n"
 "title: str, optional\n"
 "    The title to use for the annotated value when generating a json-schema.\n"
 "description: str, optional\n"
@@ -1356,20 +1373,20 @@ static PyObject *
 Meta_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) {
     static char *kwlist[] = {
         "gt", "ge", "lt", "le", "multiple_of",
-        "pattern", "min_length", "max_length",
+        "pattern", "min_length", "max_length", "tz",
         "title", "description", "examples", "extra_json_schema",
         NULL
     };
     PyObject *gt = NULL, *ge = NULL, *lt = NULL, *le = NULL, *multiple_of = NULL;
-    PyObject *pattern = NULL, *min_length = NULL, *max_length = NULL;
+    PyObject *pattern = NULL, *min_length = NULL, *max_length = NULL, *tz = NULL;
     PyObject *title = NULL, *description = NULL, *examples = NULL, *extra_json_schema = NULL;
     PyObject *regex = NULL;
 
     /* Parse arguments: (name, bases, dict) */
     if (!PyArg_ParseTupleAndKeywords(
-            args, kwargs, "|$OOOOOOOOOOOO:Meta.__new__", kwlist,
+            args, kwargs, "|$OOOOOOOOOOOOO:Meta.__new__", kwlist,
             &gt, &ge, &lt, &le, &multiple_of,
-            &pattern, &min_length, &max_length,
+            &pattern, &min_length, &max_length, &tz,
             &title, &description, &examples, &extra_json_schema
         )
     )
@@ -1384,6 +1401,7 @@ Meta_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) {
     NONE_TO_NULL(pattern);
     NONE_TO_NULL(min_length);
     NONE_TO_NULL(max_length);
+    NONE_TO_NULL(tz);
     NONE_TO_NULL(title);
     NONE_TO_NULL(description);
     NONE_TO_NULL(examples);
@@ -1399,6 +1417,7 @@ Meta_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) {
     if (pattern != NULL && !ensure_is_string(pattern, "pattern")) return NULL;
     if (min_length != NULL && !ensure_is_nonnegative_integer(min_length, "min_length")) return NULL;
     if (max_length != NULL && !ensure_is_nonnegative_integer(max_length, "max_length")) return NULL;
+    if (tz != NULL && !ensure_is_bool(tz, "tz")) return NULL;
 
     /* Check multiple constraint restrictions */
     if (gt != NULL && ge != NULL) {
@@ -1410,12 +1429,12 @@ Meta_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) {
         return NULL;
     }
     bool numeric = (gt != NULL || ge != NULL || lt != NULL || le != NULL || multiple_of != NULL);
-    bool other = (pattern != NULL || min_length != NULL || max_length != NULL);
+    bool other = (pattern != NULL || min_length != NULL || max_length != NULL || tz != NULL);
     if (numeric && other) {
         PyErr_SetString(
             PyExc_ValueError,
             "Cannot mix numeric constraints (gt, lt, ...) with non-numeric "
-            "constraints (pattern, min_length, max_length)"
+            "constraints (pattern, min_length, max_length, tz)"
         );
         return NULL;
     }
@@ -1460,6 +1479,7 @@ Meta_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) {
     SET_FIELD(regex);
     SET_FIELD(min_length);
     SET_FIELD(max_length);
+    SET_FIELD(tz);
     SET_FIELD(title);
     SET_FIELD(description);
     SET_FIELD(examples);
@@ -1487,6 +1507,7 @@ Meta_clear(Meta *self) {
     Py_CLEAR(self->regex);
     Py_CLEAR(self->min_length);
     Py_CLEAR(self->max_length);
+    Py_CLEAR(self->tz);
     Py_CLEAR(self->title);
     Py_CLEAR(self->description);
     Py_CLEAR(self->examples);
@@ -1538,6 +1559,7 @@ Meta_repr(Meta *self) {
     DO_REPR(pattern);
     DO_REPR(min_length);
     DO_REPR(max_length);
+    DO_REPR(tz);
     DO_REPR(title);
     DO_REPR(description);
     DO_REPR(examples);
@@ -1568,6 +1590,7 @@ Meta_rich_repr(PyObject *py_self, PyObject *args) {
     DO_REPR(pattern);
     DO_REPR(min_length);
     DO_REPR(max_length);
+    DO_REPR(tz);
     DO_REPR(title);
     DO_REPR(description);
     DO_REPR(examples);
@@ -1620,6 +1643,7 @@ Meta_richcompare(Meta *self, PyObject *py_other, int op) {
         DO_COMPARE(pattern);
         DO_COMPARE(min_length);
         DO_COMPARE(max_length);
+        DO_COMPARE(tz);
         DO_COMPARE(title);
         DO_COMPARE(description);
         DO_COMPARE(examples);
@@ -1659,6 +1683,7 @@ Meta_hash(Meta *self) {
     DO_HASH(pattern);
     DO_HASH(min_length);
     DO_HASH(max_length);
+    DO_HASH(tz);
     DO_HASH(title);
     DO_HASH(description);
     /* Leave out examples & description, since they could be unhashable */
@@ -1681,6 +1706,7 @@ static PyMemberDef Meta_members[] = {
     {"pattern", T_OBJECT, offsetof(Meta, pattern), READONLY, NULL},
     {"min_length", T_OBJECT, offsetof(Meta, min_length), READONLY, NULL},
     {"max_length", T_OBJECT, offsetof(Meta, max_length), READONLY, NULL},
+    {"tz", T_OBJECT, offsetof(Meta, tz), READONLY, NULL},
     {"title", T_OBJECT, offsetof(Meta, title), READONLY, NULL},
     {"description", T_OBJECT, offsetof(Meta, description), READONLY, NULL},
     {"examples", T_OBJECT, offsetof(Meta, examples), READONLY, NULL},
@@ -1720,45 +1746,48 @@ 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_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)
+#define MS_TYPE_TIME                (1ull << 10)
+#define MS_TYPE_UUID                (1ull << 11)
+#define MS_TYPE_EXT                 (1ull << 12)
+#define MS_TYPE_STRUCT              (1ull << 13)
+#define MS_TYPE_STRUCT_ARRAY        (1ull << 14)
+#define MS_TYPE_STRUCT_UNION        (1ull << 15)
+#define MS_TYPE_STRUCT_ARRAY_UNION  (1ull << 16)
+#define MS_TYPE_ENUM                (1ull << 17)
+#define MS_TYPE_INTENUM             (1ull << 18)
+#define MS_TYPE_CUSTOM              (1ull << 19)
+#define MS_TYPE_CUSTOM_GENERIC      (1ull << 20)
+#define MS_TYPE_DICT                ((1ull << 21) | (1ull << 22))
+#define MS_TYPE_LIST                (1ull << 23)
+#define MS_TYPE_SET                 (1ull << 24)
+#define MS_TYPE_FROZENSET           (1ull << 25)
+#define MS_TYPE_VARTUPLE            (1ull << 26)
+#define MS_TYPE_FIXTUPLE            (1ull << 27)
+#define MS_TYPE_INTLITERAL          (1ull << 28)
+#define MS_TYPE_STRLITERAL          (1ull << 29)
+#define MS_TYPE_TYPEDDICT           (1ull << 30)
+#define MS_TYPE_DATACLASS           (1ull << 31)
+#define MS_TYPE_NAMEDTUPLE          (1ull << 32)
 /* Constraints */
-#define MS_CONSTR_INT_MIN           (1ull << 32)
-#define MS_CONSTR_INT_MAX           (1ull << 33)
-#define MS_CONSTR_INT_MULTIPLE_OF   (1ull << 34)
-#define MS_CONSTR_FLOAT_GT          (1ull << 35)
-#define MS_CONSTR_FLOAT_GE          (1ull << 36)
-#define MS_CONSTR_FLOAT_LT          (1ull << 37)
-#define MS_CONSTR_FLOAT_LE          (1ull << 38)
-#define MS_CONSTR_FLOAT_MULTIPLE_OF (1ull << 39)
-#define MS_CONSTR_STR_REGEX         (1ull << 40)
-#define MS_CONSTR_STR_MIN_LENGTH    (1ull << 41)
-#define MS_CONSTR_STR_MAX_LENGTH    (1ull << 42)
-#define MS_CONSTR_BYTES_MIN_LENGTH  (1ull << 43)
-#define MS_CONSTR_BYTES_MAX_LENGTH  (1ull << 44)
-#define MS_CONSTR_ARRAY_MIN_LENGTH  (1ull << 45)
-#define MS_CONSTR_ARRAY_MAX_LENGTH  (1ull << 46)
-#define MS_CONSTR_MAP_MIN_LENGTH    (1ull << 47)
-#define MS_CONSTR_MAP_MAX_LENGTH    (1ull << 48)
+#define MS_CONSTR_INT_MIN           (1ull << 42)
+#define MS_CONSTR_INT_MAX           (1ull << 43)
+#define MS_CONSTR_INT_MULTIPLE_OF   (1ull << 44)
+#define MS_CONSTR_FLOAT_GT          (1ull << 45)
+#define MS_CONSTR_FLOAT_GE          (1ull << 46)
+#define MS_CONSTR_FLOAT_LT          (1ull << 47)
+#define MS_CONSTR_FLOAT_LE          (1ull << 48)
+#define MS_CONSTR_FLOAT_MULTIPLE_OF (1ull << 49)
+#define MS_CONSTR_STR_REGEX         (1ull << 50)
+#define MS_CONSTR_STR_MIN_LENGTH    (1ull << 51)
+#define MS_CONSTR_STR_MAX_LENGTH    (1ull << 52)
+#define MS_CONSTR_BYTES_MIN_LENGTH  (1ull << 53)
+#define MS_CONSTR_BYTES_MAX_LENGTH  (1ull << 54)
+#define MS_CONSTR_ARRAY_MIN_LENGTH  (1ull << 55)
+#define MS_CONSTR_ARRAY_MAX_LENGTH  (1ull << 56)
+#define MS_CONSTR_MAP_MIN_LENGTH    (1ull << 57)
+#define MS_CONSTR_MAP_MAX_LENGTH    (1ull << 58)
+#define MS_CONSTR_TZ_AWARE          (1ull << 59)
+#define MS_CONSTR_TZ_NAIVE          (1ull << 60)
 /* Extra flag bit, used by TypedDict/dataclass implementations */
 #define MS_EXTRA_FLAG               (1ull << 63)
 
@@ -2338,6 +2367,7 @@ typedef struct {
     PyObject *regex;
     PyObject *min_length;
     PyObject *max_length;
+    PyObject *tz;
 } Constraints;
 
 typedef struct {
@@ -2389,7 +2419,8 @@ constraints_is_empty(Constraints *self) {
         self->multiple_of == NULL &&
         self->regex == NULL &&
         self->min_length == NULL &&
-        self->max_length == NULL
+        self->max_length == NULL &&
+        self->tz == NULL
     );
 }
 
@@ -2422,6 +2453,7 @@ constraints_update(Constraints *self, Meta *meta, PyObject *type) {
     set_constraint(regex);
     set_constraint(min_length);
     set_constraint(max_length);
+    set_constraint(tz);
     if (self->gt != NULL && self->ge != NULL) {
         PyErr_Format(
             PyExc_TypeError,
@@ -2449,9 +2481,10 @@ enum constraint_kind {
     CK_FLOAT = 1,
     CK_STR = 2,
     CK_BYTES = 3,
-    CK_ARRAY = 4,
-    CK_MAP = 5,
-    CK_OTHER = 6,
+    CK_TIME = 4,
+    CK_ARRAY = 5,
+    CK_MAP = 6,
+    CK_OTHER = 7,
 };
 
 static int
@@ -2551,6 +2584,9 @@ typenode_collect_constraints(
         if (constraints->min_length != NULL) return err_invalid_constraint("min_length", "str, bytes, or collection", obj);
         if (constraints->max_length != NULL) return err_invalid_constraint("max_length", "str, bytes, or collection", obj);
     }
+    if (kind != CK_TIME) {
+        if (constraints->tz != NULL) return err_invalid_constraint("tz", "datetime or time", obj);
+    }
 
     /* Next attempt to fill in the state. */
     if (kind == CK_INT) {
@@ -2623,6 +2659,16 @@ typenode_collect_constraints(
             if (!_constr_as_py_ssize_t(constraints->max_length, &(state->c_bytes_max_length))) return -1;
         }
     }
+    else if (kind == CK_TIME) {
+        if (constraints->tz != NULL) {
+            if (constraints->tz == Py_True) {
+                state->types |= MS_CONSTR_TZ_AWARE;
+            }
+            else {
+                state->types |= MS_CONSTR_TZ_NAIVE;
+            }
+        }
+    }
     else if (kind == CK_ARRAY) {
         if (constraints->min_length != NULL) {
             state->types |= MS_CONSTR_ARRAY_MIN_LENGTH;
@@ -3001,15 +3047,15 @@ typenode_collect_check_invariants(
     if (ms_popcount(
             state->types & (
                 MS_TYPE_STR | MS_TYPE_STRLITERAL | MS_TYPE_ENUM |
-                MS_TYPE_DATE | MS_TYPE_UUID
+                MS_TYPE_DATETIME | 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`, `uuid`) - type `%R` is not "
-            "supported",
+            "`Enum`, `Literal[str values]`, `datetime`, `date`, `uuid`) - "
+            "type `%R` is not supported",
             state->context
         );
         return -1;
@@ -3029,8 +3075,8 @@ typenode_collect_check_invariants(
             PyErr_Format(
                 PyExc_TypeError,
                 "JSON type unions may not contain more than one str-like type "
-                "(`str`, `Enum`, `Literal[str values]`, `bytes`, `bytearray`, "
-                "`datetime`, `date`, `uuid`) - type `%R` is not supported",
+                "(`str`, `Enum`, `Literal[str values]`, `datetime`, `date`, "
+                "`uuid`, `bytes`, `bytearray`) - type `%R` is not supported",
                 state->context
             );
             return -1;
@@ -3687,6 +3733,7 @@ typenode_collect_type_full(
     }
     else if (obj == (PyObject *)(PyDateTimeAPI->DateTimeType)) {
         state->types |= MS_TYPE_DATETIME;
+        *kind = CK_TIME;
         return 0;
     }
     else if (obj == (PyObject *)(PyDateTimeAPI->DateType)) {
@@ -7678,21 +7725,49 @@ ms_passes_map_constraints(Py_ssize_t size, TypeNode *type, PathNode *path) {
     return true;
 }
 
-static int
-ms_encode_err_type_unsupported(PyTypeObject *type) {
-    if (type == PyDateTimeAPI->DateTimeType) {
-        PyErr_SetString(
-            PyExc_TypeError,
-            "Encoding timezone-naive datetime objects is unsupported"
-        );
+static PyObject *
+ms_check_datetime_constraints(
+    int year, int month, int day,
+    int hour, int minute, int second, int microsecond,
+    PyObject *tz,
+    TypeNode *type, PathNode *path
+) {
+    char *err, *type_str;
+    if (tz == Py_None) {
+        if (type->types & MS_CONSTR_TZ_AWARE) {
+            err = "Expected `%s` with a timezone component%U";
+            goto error;
+        }
+    }
+    else if (type->types & MS_CONSTR_TZ_NAIVE) {
+        err = "Expected `%s` with no timezone component%U";
+        goto error;
+    }
+
+    return PyDateTimeAPI->DateTime_FromDateAndTime(
+        year, month, day, hour, minute, second, microsecond, tz,
+        PyDateTimeAPI->DateTimeType
+    );
+
+error:
+    if (type->types & MS_TYPE_TIME) {
+        type_str = "time";
     }
     else {
-        PyErr_Format(
-            PyExc_TypeError,
-            "Encoding objects of type %.200s is unsupported",
-            type->tp_name
-        );
+        type_str = "datetime";
     }
+
+    ms_raise_validation_error(path, err, type_str);
+    return NULL;
+}
+
+static int
+ms_encode_err_type_unsupported(PyTypeObject *type) {
+    PyErr_Format(
+        PyExc_TypeError,
+        "Encoding objects of type %.200s is unsupported",
+        type->tp_name
+    );
     return -1;
 }
 
@@ -7822,7 +7897,9 @@ datetime_to_epoch(PyObject *obj, int64_t *seconds, int32_t *nanoseconds) {
  * copyright Rich Felker et. al, and is licensed under the standard MIT
  * license.  */
 static PyObject *
-datetime_from_epoch(int64_t epoch_secs, uint32_t epoch_nanos) {
+datetime_from_epoch(
+    int64_t epoch_secs, uint32_t epoch_nanos, TypeNode *type, PathNode *path
+) {
     int64_t days, secs, years;
     int months, remdays, remsecs, remyears;
     int qc_cycles, c_cycles, q_cycles;
@@ -7867,7 +7944,7 @@ datetime_from_epoch(int64_t epoch_secs, uint32_t epoch_nanos) {
         years++;
     }
 
-    return PyDateTimeAPI->DateTime_FromDateAndTime(
+    return ms_check_datetime_constraints(
         years + 2000,
         months + 3,
         remdays + 1,
@@ -7876,7 +7953,8 @@ datetime_from_epoch(int64_t epoch_secs, uint32_t epoch_nanos) {
         remsecs % 60,
         epoch_nanos / 1000,
         PyDateTime_TimeZone_UTC,
-        PyDateTimeAPI->DateTimeType
+        type,
+        path
     );
 }
 
@@ -7902,6 +7980,113 @@ ms_read_fixint(const char *buf, int width, int *out) {
     return buf;
 }
 
+/* Requires 10 bytes of scratch space */
+static void
+ms_encode_date(EncoderState *self, PyObject *obj, char *out)
+{
+    uint32_t year = PyDateTime_GET_YEAR(obj);
+    uint8_t month = PyDateTime_GET_MONTH(obj);
+    uint8_t day = PyDateTime_GET_DAY(obj);
+
+    out = ms_write_fixint(out, year, 4);
+    *out++ = '-';
+    out = ms_write_fixint(out, month, 2);
+    *out++ = '-';
+    out = ms_write_fixint(out, day, 2);
+}
+
+/* Requires 32 bytes of scratch space max.
+ *
+ * Returns +nbytes if successful, -1 on failure */
+static int
+ms_encode_datetime(EncoderState *self, PyObject *obj, char *out)
+{
+    uint32_t year = PyDateTime_GET_YEAR(obj);
+    uint8_t month = PyDateTime_GET_MONTH(obj);
+    uint8_t day = PyDateTime_GET_DAY(obj);
+
+    uint8_t hour = PyDateTime_DATE_GET_HOUR(obj);
+    uint8_t minute = PyDateTime_DATE_GET_MINUTE(obj);
+    uint8_t second = PyDateTime_DATE_GET_SECOND(obj);
+    uint32_t microsecond = PyDateTime_DATE_GET_MICROSECOND(obj);
+
+    char *p = out;
+    p = ms_write_fixint(p, year, 4);
+    *p++ = '-';
+    p = ms_write_fixint(p, month, 2);
+    *p++ = '-';
+    p = ms_write_fixint(p, day, 2);
+    *p++ = 'T';
+    p = ms_write_fixint(p, hour, 2);
+    *p++ = ':';
+    p = ms_write_fixint(p, minute, 2);
+    *p++ = ':';
+    p = ms_write_fixint(p, second, 2);
+    if (microsecond) {
+        *p++ = '.';
+        p = ms_write_fixint(p, microsecond, 6);
+    }
+
+    if (MS_HAS_TZINFO(obj)) {
+        int32_t offset_days = 0, offset_secs = 0;
+        PyObject *tzinfo = MS_GET_TZINFO(obj);
+
+        if (tzinfo != PyDateTime_TimeZone_UTC) {
+            PyObject *offset = CALL_METHOD_ONE_ARG(tzinfo, self->mod->str_utcoffset, obj);
+            if (offset == NULL) return -1;
+            if (PyDelta_Check(offset)) {
+                offset_days = PyDateTime_DELTA_GET_DAYS(offset);
+                offset_secs = PyDateTime_DELTA_GET_SECONDS(offset);
+            }
+            else if (offset != Py_None) {
+                PyErr_SetString(
+                    PyExc_TypeError,
+                    "datetime.tzinfo.utcoffset returned a non-timedelta object"
+                );
+                Py_DECREF(offset);
+                return -1;
+            }
+            Py_DECREF(offset);
+        }
+        if (MS_LIKELY(offset_secs == 0)) {
+            *p++ = 'Z';
+        }
+        else {
+            char sign = '+';
+            if (offset_days == -1) {
+                sign = '-';
+                offset_secs = 86400 - offset_secs;
+            }
+            uint8_t offset_hour = offset_secs / 3600;
+            uint8_t offset_min = (offset_secs / 60) % 60;
+            /* If the offset isn't an even number of minutes, RFC 3339
+            * indicates that the offset should be rounded to the nearest
+            * possible hour:min pair */
+            bool round_up = (offset_secs - (offset_hour * 3600 + offset_min * 60)) > 30;
+            if (MS_UNLIKELY(round_up)) {
+                offset_min++;
+                if (offset_min == 60) {
+                    offset_min = 0;
+                    offset_hour++;
+                    if (offset_hour == 24) {
+                        offset_hour = 0;
+                    }
+                }
+            }
+            if (offset_hour == 0 && offset_min == 0) {
+                *p++ = 'Z';
+            }
+            else {
+                *p++ = sign;
+                p = ms_write_fixint(p, offset_hour, 2);
+                *p++ = ':';
+                p = ms_write_fixint(p, offset_min, 2);
+            }
+        }
+    }
+    return p - out;
+}
+
 static PyObject *
 ms_decode_date(const char *buf, Py_ssize_t size, PathNode *path) {
     int year, month, day;
@@ -7928,14 +8113,15 @@ invalid:
 }
 
 static PyObject *
-ms_decode_datetime(const char *buf, Py_ssize_t size, PathNode *path) {
+ms_decode_datetime(const char *buf, Py_ssize_t size, TypeNode *type, PathNode *path) {
     int year, month, day, hour, minute, second, microsecond = 0, offset = 0;
     const char *buf_end = buf + size;
     bool round_up_micros = false;
+    PyObject *tz = Py_None;
     char c;
 
-    /* A valid datetime is at least 20 characters in length */
-    if (size < 20) goto invalid;
+    /* A valid datetime is at least 19 characters in length */
+    if (size < 19) goto invalid;
 
     /* Parse date */
     if ((buf = ms_read_fixint(buf, 4, &year)) == NULL) goto invalid;
@@ -7955,8 +8141,9 @@ ms_decode_datetime(const char *buf, Py_ssize_t size, PathNode *path) {
     if (*buf++ != ':') goto invalid;
     if ((buf = ms_read_fixint(buf, 2, &second)) == NULL) goto invalid;
 
-    /* This is the last read that doesn't need a bounds check */
-    c = *buf++;
+    /* Remaining reads require bounds check */
+#define next_or_null() (buf == buf_end) ? '\0' : *buf++
+    c = next_or_null();
 
     /* Parse decimal if present.
      *
@@ -7969,13 +8156,13 @@ ms_decode_datetime(const char *buf, Py_ssize_t size, PathNode *path) {
      * precision is rare. */
     if (c == '.') {
         int ndigits = 0;
-        while (buf < buf_end && ndigits < 6) {
-            c = *buf++;
+        while (ndigits < 6) {
+            c = next_or_null();
             if (!is_digit(c)) goto end_decimal;
             ndigits++;
             microsecond = microsecond * 10 + (c - '0');
         }
-        c = *buf++;
+        c = next_or_null();
         if (is_digit(c)) {
             /* This timestamp has higher precision than microseconds; parse
             * the next digit to support rounding, then skip all remaining
@@ -7983,8 +8170,8 @@ ms_decode_datetime(const char *buf, Py_ssize_t size, PathNode *path) {
             if ((c - '0') >= 5) {
                 round_up_micros = true;
             }
-            while (buf < buf_end) {
-                c = *buf++;
+            while (true) {
+                c = next_or_null();
                 if (!is_digit(c)) break;
             }
         }
@@ -7995,9 +8182,16 @@ end_decimal:
         /* Scale microseconds appropriately */
         microsecond *= pow10[ndigits - 1];
     }
+#undef next_or_null
 
     /* Parse timezone */
-    if (!(c == 'Z' || c == 'z')) {
+    if (c == 'Z' || c == 'z') {
+        tz = PyDateTime_TimeZone_UTC;
+
+        /* Check for trailing characters */
+        if (buf != buf_end) goto invalid;
+    }
+    else if (c != '\0') {
         int offset_hour, offset_min;
         if (c == '-') {
             offset = -1;
@@ -8009,18 +8203,17 @@ end_decimal:
             goto invalid;
         }
 
-        if (buf_end - buf < 5) goto invalid;
+        /* Explicit offset requires exactly 5 bytes left */
+        if (buf_end - buf != 5) goto invalid;
 
         if ((buf = ms_read_fixint(buf, 2, &offset_hour)) == NULL) goto invalid;
         if (*buf++ != ':') goto invalid;
         if ((buf = ms_read_fixint(buf, 2, &offset_min)) == NULL) goto invalid;
         if (offset_hour > 23 || offset_min > 59) goto invalid;
         offset *= (offset_hour * 60 + offset_min);
+        tz = PyDateTime_TimeZone_UTC;
     }
 
-    /* Check for trailing characters */
-    if (buf != buf_end) goto invalid;
-
     /* Ensure all numbers are valid */
     if (year == 0) goto invalid;
     if (month == 0 || month > 12) goto invalid;
@@ -8046,9 +8239,9 @@ end_decimal:
             goto invalid;
         }
     }
-    return PyDateTimeAPI->DateTime_FromDateAndTime(
-        year, month, day, hour, minute, second, microsecond,
-        PyDateTime_TimeZone_UTC, PyDateTimeAPI->DateTimeType
+
+    return ms_check_datetime_constraints(
+        year, month, day, hour, minute, second, microsecond, tz, type, path
     );
 
 invalid:
@@ -8875,18 +9068,8 @@ mpack_encode_uuid(EncoderState *self, PyObject *obj)
 static int
 mpack_encode_date(EncoderState *self, PyObject *obj)
 {
-    uint32_t year = PyDateTime_GET_YEAR(obj);
-    uint8_t month = PyDateTime_GET_MONTH(obj);
-    uint8_t day = PyDateTime_GET_DAY(obj);
-
     char buf[10];
-    char *p = buf;
-
-    p = ms_write_fixint(p, year, 4);
-    *p++ = '-';
-    p = ms_write_fixint(p, month, 2);
-    *p++ = '-';
-    p = ms_write_fixint(p, day, 2);
+    ms_encode_date(self, obj, buf);
     return mpack_encode_cstr(self, buf, 10);
 }
 
@@ -8897,6 +9080,13 @@ mpack_encode_datetime(EncoderState *self, PyObject *obj)
     int32_t nanoseconds;
     PyObject *tzinfo;
 
+    if (!MS_HAS_TZINFO(obj)) {
+        char buf[32];
+        int size = ms_encode_datetime(self, obj, buf);
+        if (size < 0) return -1;
+        return mpack_encode_cstr(self, buf, size);
+    }
+
     tzinfo = MS_GET_TZINFO(obj);
     if (tzinfo == PyDateTime_TimeZone_UTC) {
         datetime_to_epoch(obj, &seconds, &nanoseconds);
@@ -8987,7 +9177,7 @@ mpack_encode(EncoderState *self, PyObject *obj)
     else if (PyTuple_Check(obj)) {
         return mpack_encode_tuple(self, obj);
     }
-    else if (type == PyDateTimeAPI->DateTimeType && MS_HAS_TZINFO(obj)) {
+    else if (type == PyDateTimeAPI->DateTimeType) {
         return mpack_encode_datetime(self, obj);
     }
     else if (type == PyDateTimeAPI->DateType) {
@@ -9408,113 +9598,22 @@ json_encode_uuid(EncoderState *self, PyObject *obj)
 static int
 json_encode_date(EncoderState *self, PyObject *obj)
 {
-    uint32_t year = PyDateTime_GET_YEAR(obj);
-    uint8_t month = PyDateTime_GET_MONTH(obj);
-    uint8_t day = PyDateTime_GET_DAY(obj);
-
     char buf[12];
-    char *p = buf;
-
-    *p++ = '"';
-    p = ms_write_fixint(p, year, 4);
-    *p++ = '-';
-    p = ms_write_fixint(p, month, 2);
-    *p++ = '-';
-    p = ms_write_fixint(p, day, 2);
-    *p++ = '"';
+    buf[0] = '"';
+    buf[11] = '"';
+    ms_encode_date(self, obj, buf + 1);
     return ms_write(self, buf, 12);
 }
 
 static int
 json_encode_datetime(EncoderState *self, PyObject *obj)
 {
-    uint32_t year = PyDateTime_GET_YEAR(obj);
-    uint8_t month = PyDateTime_GET_MONTH(obj);
-    uint8_t day = PyDateTime_GET_DAY(obj);
-
-    uint8_t hour = PyDateTime_DATE_GET_HOUR(obj);
-    uint8_t minute = PyDateTime_DATE_GET_MINUTE(obj);
-    uint8_t second = PyDateTime_DATE_GET_SECOND(obj);
-    uint32_t microsecond = PyDateTime_DATE_GET_MICROSECOND(obj);
-    int32_t offset_days = 0, offset_secs = 0;
-    PyObject *tzinfo;
-
-    tzinfo = MS_GET_TZINFO(obj);
-    if (tzinfo != PyDateTime_TimeZone_UTC) {
-        PyObject *offset = CALL_METHOD_ONE_ARG(tzinfo, self->mod->str_utcoffset, obj);
-        if (offset == NULL) return -1;
-        if (PyDelta_Check(offset)) {
-            offset_days = PyDateTime_DELTA_GET_DAYS(offset);
-            offset_secs = PyDateTime_DELTA_GET_SECONDS(offset);
-        }
-        else if (offset != Py_None) {
-            PyErr_SetString(
-                PyExc_TypeError,
-                "datetime.tzinfo.utcoffset returned a non-timedelta object"
-            );
-            Py_DECREF(offset);
-            return -1;
-        }
-        Py_DECREF(offset);
-    }
-
     char buf[34];
-    char *p = buf;
-    memset(p, '0', 34);
-
-    *p++ = '"';
-    p = ms_write_fixint(p, year, 4);
-    *p++ = '-';
-    p = ms_write_fixint(p, month, 2);
-    *p++ = '-';
-    p = ms_write_fixint(p, day, 2);
-    *p++ = 'T';
-    p = ms_write_fixint(p, hour, 2);
-    *p++ = ':';
-    p = ms_write_fixint(p, minute, 2);
-    *p++ = ':';
-    p = ms_write_fixint(p, second, 2);
-    if (microsecond) {
-        *p++ = '.';
-        p = ms_write_fixint(p, microsecond, 6);
-    }
-    if (MS_LIKELY(offset_secs == 0)) {
-        *p++ = 'Z';
-    }
-    else {
-        char sign = '+';
-        if (offset_days == -1) {
-            sign = '-';
-            offset_secs = 86400 - offset_secs;
-        }
-        uint8_t offset_hour = offset_secs / 3600;
-        uint8_t offset_min = (offset_secs / 60) % 60;
-        /* If the offset isn't an even number of minutes, RFC 3339
-        * indicates that the offset should be rounded to the nearest
-        * possible hour:min pair */
-        bool round_up = (offset_secs - (offset_hour * 3600 + offset_min * 60)) > 30;
-        if (MS_UNLIKELY(round_up)) {
-            offset_min++;
-            if (offset_min == 60) {
-                offset_min = 0;
-                offset_hour++;
-                if (offset_hour == 24) {
-                    offset_hour = 0;
-                }
-            }
-        }
-        if (offset_hour == 0 && offset_min == 0) {
-            *p++ = 'Z';
-        }
-        else {
-            *p++ = sign;
-            p = ms_write_fixint(p, offset_hour, 2);
-            *p++ = ':';
-            p = ms_write_fixint(p, offset_min, 2);
-        }
-    }
-    *p++ = '"';
-    return ms_write(self, buf, p - buf);
+    buf[0] = '"';
+    int size = ms_encode_datetime(self, obj, buf + 1);
+    if (size < 0) return -1;
+    buf[size + 1] = '"';
+    return ms_write(self, buf, size + 2);
 }
 
 static int
@@ -9863,7 +9962,7 @@ json_encode(EncoderState *self, PyObject *obj)
     else if (Py_TYPE(type) == &StructMetaType) {
         return json_encode_struct(self, obj);
     }
-    else if (type == PyDateTimeAPI->DateTimeType && MS_HAS_TZINFO(obj)) {
+    else if (type == PyDateTimeAPI->DateTimeType) {
         return json_encode_datetime(self, obj);
     }
     else if (type == PyDateTimeAPI->DateType) {
@@ -10339,7 +10438,8 @@ mpack_decode_cint(DecoderState *self, int64_t *out, uint64_t *uout, PathNode *pa
 
 static PyObject *
 mpack_decode_datetime(
-    DecoderState *self, const char *data_buf, Py_ssize_t size, PathNode *path
+    DecoderState *self, const char *data_buf, Py_ssize_t size,
+    TypeNode *type, PathNode *path
 ) {
     uint64_t data64;
     uint32_t nanoseconds;
@@ -10375,7 +10475,7 @@ mpack_decode_datetime(
         err_msg = "Timestamp is out of range%U";
         goto invalid;
     }
-    return datetime_from_epoch(seconds, nanoseconds);
+    return datetime_from_epoch(seconds, nanoseconds, type, path);
 
 invalid:
     return ms_error_with_path(err_msg, path);
@@ -10561,7 +10661,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_UUID
+                MS_TYPE_DATETIME | MS_TYPE_DATE | MS_TYPE_UUID
             )
         )
     ) {
@@ -10570,6 +10670,9 @@ mpack_decode_str(DecoderState *self, Py_ssize_t size, TypeNode *type, PathNode *
         if (MS_UNLIKELY(type->types & (MS_TYPE_ENUM | MS_TYPE_STRLITERAL))) {
             return ms_decode_str_enum_or_literal(s, size, type, path);
         }
+        if (MS_UNLIKELY(type->types & MS_TYPE_DATETIME)) {
+            return ms_decode_datetime(s, size, type, path);
+        }
         if (MS_UNLIKELY(type->types & MS_TYPE_DATE)) {
             return ms_decode_date(s, size, path);
         }
@@ -11386,7 +11489,7 @@ mpack_decode_ext(
     if (mpack_read(self, &data_buf, size) < 0) return NULL;
 
     if (type->types & MS_TYPE_DATETIME && code == -1) {
-        return mpack_decode_datetime(self, data_buf, size, path);
+        return mpack_decode_datetime(self, data_buf, size, type, path);
     }
     else if (type->types & MS_TYPE_EXT) {
         data = PyBytes_FromStringAndSize(data_buf, size);
@@ -11403,7 +11506,7 @@ mpack_decode_ext(
      * - otherwise return Ext object
      * */
     if (code == -1) {
-        return mpack_decode_datetime(self, data_buf, size, path);
+        return mpack_decode_datetime(self, data_buf, size, type, path);
     }
     else if (self->ext_hook == NULL) {
         data = PyBytes_FromStringAndSize(data_buf, size);
@@ -12621,7 +12724,7 @@ json_decode_string(JSONDecoderState *self, TypeNode *type, PathNode *path) {
             return ms_check_str_constraints(out, type, path);
         }
         else if (MS_UNLIKELY(type->types & MS_TYPE_DATETIME)) {
-            return ms_decode_datetime(view, size, path);
+            return ms_decode_datetime(view, size, type, path);
         }
         else if (MS_UNLIKELY(type->types & MS_TYPE_DATE)) {
             return ms_decode_date(view, size, path);
diff --git a/msgspec/_utils.py b/msgspec/_utils.py
index 1d66b5c..29f0f33 100644
--- a/msgspec/_utils.py
+++ b/msgspec/_utils.py
@@ -379,6 +379,9 @@ class SchemaBuilder:
                 schema["multipleOf"] = meta.multiple_of
             if meta.pattern is not None:
                 schema["pattern"] = meta.pattern
+            if meta.tz is True:
+                if t is datetime.datetime:
+                    schema["format"] = "date-time"
             if t is str:
                 if meta.max_length is not None:
                     schema["maxLength"] = meta.max_length
@@ -431,7 +434,6 @@ class SchemaBuilder:
             schema["contentEncoding"] = "base64"
         elif t is datetime.datetime:
             schema["type"] = "string"
-            schema["format"] = "date-time"
         elif t is datetime.date:
             schema["type"] = "string"
             schema["format"] = "date"


Test patch:
diff --git a/tests/basic_typing_examples.py b/tests/basic_typing_examples.py
index 809a001..de6ab08 100644
--- a/tests/basic_typing_examples.py
+++ b/tests/basic_typing_examples.py
@@ -296,10 +296,12 @@ def check_meta_constructor() -> None:
     for val3 in [1, None]:
         msgspec.Meta(min_length=val3)
         msgspec.Meta(max_length=val3)
-    for val4 in [[1, 2, 3], None]:
-        msgspec.Meta(examples=val4)
-    for val5 in [{"foo": "bar"}, None]:
-        msgspec.Meta(extra_json_schema=val5)
+    for val4 in [True, False, None]:
+        msgspec.Meta(tz=val4)
+    for val5 in [[1, 2, 3], None]:
+        msgspec.Meta(examples=val5)
+    for val6 in [{"foo": "bar"}, None]:
+        msgspec.Meta(extra_json_schema=val6)
 
 
 def check_meta_attributes() -> None:
@@ -312,6 +314,7 @@ def check_meta_attributes() -> None:
     print(c.pattern)
     print(c.min_length)
     print(c.max_length)
+    print(c.tz)
     print(c.title)
     print(c.description)
     print(c.examples)
diff --git a/tests/test_constraints.py b/tests/test_constraints.py
index e1d2111..1fab54f 100644
--- a/tests/test_constraints.py
+++ b/tests/test_constraints.py
@@ -1,3 +1,4 @@
+import datetime
 import math
 import re
 from typing import Dict, Union, List
@@ -53,6 +54,7 @@ FIELDS = {
     "pattern": "^foo$",
     "min_length": 0,
     "max_length": 10,
+    "tz": True,
     "title": "example title",
     "description": "example description",
     "examples": ["example 1", "example 2"],
@@ -155,6 +157,8 @@ class TestMetaObject:
             val2 = {}
         elif isinstance(val, list):
             val2 = []
+        elif isinstance(val, bool):
+            val2 = not val
         elif isinstance(val, int):
             val2 = val + 25
         else:
@@ -202,6 +206,13 @@ class TestMetaObject:
         with pytest.raises(TypeError, match=f"`{field}` must be a str, got bytes"):
             Meta(**{field: b"bad"})
 
+    @pytest.mark.parametrize("field", ["tz"])
+    def test_bool_fields(self, field):
+        Meta(**{field: True})
+        Meta(**{field: False})
+        with pytest.raises(TypeError, match=f"`{field}` must be a bool, got float"):
+            Meta(**{field: 1.5})
+
     @pytest.mark.parametrize("field", ["examples"])
     def test_list_fields(self, field):
         Meta(**{field: ["good", "stuff"]})
@@ -256,6 +267,13 @@ class TestInvalidConstraintAnnotations:
         ):
             msgspec.json.Decoder(Annotated[int, Meta(**{name: 1})])
 
+    def test_invalid_tz_constraint(self):
+        with pytest.raises(
+            TypeError,
+            match="Can only set `tz` on a datetime or time type",
+        ):
+            msgspec.json.Decoder(Annotated[int, Meta(tz=True)])
+
     @pytest.mark.parametrize(
         "name, val",
         [("ge", 2**63), ("gt", 2**63 - 1), ("le", 2**63), ("lt", -(2**63))],
@@ -518,6 +536,52 @@ class TestStrConstraints:
                 dec.decode(proto.encode(Ex(x)))
 
 
+class TestDateTimeConstraints:
+    @staticmethod
+    def roundtrip(proto, cls, aware, as_str):
+        dt = datetime.datetime.now(datetime.timezone.utc if aware else None)
+
+        if as_str:
+            s = proto.encode(cls(dt.isoformat()))
+        else:
+            s = proto.encode(cls(dt))
+
+        res = proto.decode(s, type=cls)
+        assert res.x == dt
+
+    @pytest.mark.parametrize("as_str", [True, False])
+    def test_tz_none(self, proto, as_str):
+        class Ex(msgspec.Struct):
+            x: Annotated[datetime.datetime, Meta(tz=None)]
+
+        self.roundtrip(proto, Ex, True, as_str)
+        self.roundtrip(proto, Ex, False, as_str)
+
+    @pytest.mark.parametrize("as_str", [True, False])
+    def test_tz_false(self, proto, as_str):
+        class Ex(msgspec.Struct):
+            x: Annotated[datetime.datetime, Meta(tz=False)]
+
+        self.roundtrip(proto, Ex, False, as_str)
+
+        err_msg = r"Expected `datetime` with no timezone component - at `\$.x`"
+
+        with pytest.raises(msgspec.ValidationError, match=err_msg):
+            self.roundtrip(proto, Ex, True, as_str)
+
+    @pytest.mark.parametrize("as_str", [True, False])
+    def test_tz_true(self, proto, as_str):
+        class Ex(msgspec.Struct):
+            x: Annotated[datetime.datetime, Meta(tz=True)]
+
+        self.roundtrip(proto, Ex, True, as_str)
+
+        err_msg = r"Expected `datetime` with a timezone component - at `\$.x`"
+
+        with pytest.raises(msgspec.ValidationError, match=err_msg):
+            self.roundtrip(proto, Ex, False, as_str)
+
+
 class TestBytesConstraints:
     @pytest.mark.parametrize("typ", [bytes, bytearray])
     def test_min_length(self, proto, typ):
diff --git a/tests/test_json.py b/tests/test_json.py
index 2055188..0e15626 100644
--- a/tests/test_json.py
+++ b/tests/test_json.py
@@ -842,6 +842,25 @@ class TestDatetime:
         s = msgspec.json.encode(x)
         assert s == b'"1234-12-31T14:56:27Z"'
 
+    @pytest.mark.parametrize(
+        "dt, sol",
+        [
+            (datetime.datetime(1, 2, 3, 4, 5, 6), b'"0001-02-03T04:05:06"'),
+            (datetime.datetime(1234, 12, 31, 14, 56, 27, 0), b'"1234-12-31T14:56:27"'),
+            (
+                datetime.datetime(1234, 12, 31, 14, 56, 27, 7),
+                b'"1234-12-31T14:56:27.000007"',
+            ),
+            (
+                datetime.datetime(1234, 12, 31, 14, 56, 27, 123456),
+                b'"1234-12-31T14:56:27.123456"',
+            ),
+        ],
+    )
+    def test_encode_datetime_naive(self, dt, sol):
+        res = msgspec.json.encode(dt)
+        assert res == sol
+
     @pytest.mark.parametrize(
         "offset",
         [
@@ -883,25 +902,6 @@ class TestDatetime:
         s = msgspec.json.encode(x)
         assert s == expected
 
-    def test_encode_datetime_no_tzinfo_errors_by_default(self):
-        x = datetime.datetime.now()
-        with pytest.raises(
-            TypeError, match="Encoding timezone-naive datetime objects is unsupported"
-        ):
-            msgspec.json.encode(x)
-
-    def test_encode_datetime_no_tzinfo_hits_enc_hook(self):
-        x = datetime.datetime.now()
-        res = msgspec.json.encode(x.replace(tzinfo=UTC))
-
-        def enc_hook(obj):
-            if isinstance(obj, datetime.datetime):
-                return obj.replace(tzinfo=UTC)
-            raise TypeError(str(type(obj)))
-
-        sol = msgspec.json.encode(x, enc_hook=enc_hook)
-        assert res == sol
-
     @pytest.mark.parametrize(
         "dt",
         [
@@ -941,6 +941,20 @@ class TestDatetime:
         res = msgspec.json.decode(json_s, type=datetime.datetime)
         assert res == exp
 
+    @pytest.mark.parametrize(
+        "s",
+        [
+            "1234-01-02T03:04:05",
+            "1234-01-02T03:04:05.123",
+            "1234-01-02T03:04:05.123456",
+        ],
+    )
+    def test_decode_datetime_naive(self, s):
+        sol = datetime.datetime.fromisoformat(s)
+        msg = f'"{s}"'.encode("utf-8")
+        res = msgspec.json.decode(msg, type=datetime.datetime)
+        assert sol == res
+
     @pytest.mark.parametrize("t", ["T", "t"])
     @pytest.mark.parametrize("z", ["Z", "z"])
     def test_decode_datetime_not_case_sensitive(self, t, z):
@@ -1016,16 +1030,16 @@ class TestDatetime:
             b'"0001-02-03T04:05:06.000007+0:00"',
             b'"0001-02-03T04:05:06.000007+00:0"',
             # Trailing data
-            b'"0001-02-03T04:05:06.000007+00:00:00"',
+            b'"0001-02-03T04:05:06.000007+00:000"',
+            b'"0001-02-03T04:05:06.000007Z0"',
+            b'"0001-02-03T04:05:06a"',
+            b'"0001-02-03T04:05:06.000007a"',
             # Truncated
             b'"0001-02-03T04:05:"',
-            # Missing timezone
-            b'"0001-02-03T04:05:06"',
-            b'"0001-02-03T04:05:06.000001"',
-            b'"0001-02-03T04:05:06.00000001"',
             # Missing +/-
             b'"0001-02-03T04:05:06.00000700:00"',
             # Missing digits after decimal
+            b'"0001-02-03T04:05:06."',
             b'"0001-02-03T04:05:06.Z"',
             # Invalid characters
             b'"000a-02-03T04:05:06.000007Z"',
diff --git a/tests/test_msgpack.py b/tests/test_msgpack.py
index 2776c4a..0e66f9e 100644
--- a/tests/test_msgpack.py
+++ b/tests/test_msgpack.py
@@ -493,23 +493,19 @@ class TestEncoderMisc:
         res = msgspec.msgpack.decode(msg)
         assert buf == res
 
-    def test_encode_datetime_no_tzinfo_errors_by_default(self):
-        x = datetime.datetime.now()
-        with pytest.raises(
-            TypeError, match="Encoding timezone-naive datetime objects is unsupported"
-        ):
-            msgspec.msgpack.encode(x)
-
-    def test_encode_datetime_no_tzinfo_hits_enc_hook(self):
-        x = datetime.datetime.now()
-        res = msgspec.msgpack.encode(x.replace(tzinfo=datetime.timezone.utc))
-
-        def enc_hook(obj):
-            if isinstance(obj, datetime.datetime):
-                return obj.replace(tzinfo=datetime.timezone.utc)
-            raise TypeError(str(type(obj)))
-
-        sol = msgspec.msgpack.encode(x, enc_hook=enc_hook)
+    @pytest.mark.parametrize(
+        "dt, dt_str",
+        [
+            (datetime.datetime(1, 2, 3, 4, 5, 6), "0001-02-03T04:05:06"),
+            (
+                datetime.datetime(1234, 12, 31, 14, 56, 27, 123456),
+                "1234-12-31T14:56:27.123456",
+            ),
+        ],
+    )
+    def test_encode_datetime_naive(self, dt, dt_str):
+        res = msgspec.msgpack.encode(dt)
+        sol = msgspec.msgpack.encode(dt_str)
         assert res == sol
 
     def test_encode_datetime_non_utc_tzinfo(self):
@@ -810,12 +806,40 @@ class TestTypedDecoder:
     def test_bytearray_unexpected_type(self):
         self.check_unexpected_type(bytearray, 1, "Expected `bytes`")
 
-    def test_datetime(self):
+    def test_datetime_aware_ext(self):
         dec = msgspec.msgpack.Decoder(datetime.datetime)
         x = datetime.datetime.now(UTC)
         res = dec.decode(msgspec.msgpack.encode(x))
         assert x == res
 
+    @pytest.mark.parametrize(
+        "s",
+        [
+            "1234-01-02T03:04:05Z",
+            "1234-01-02T03:04:05.123Z",
+            "1234-01-02T03:04:05.123+00:00",
+        ],
+    )
+    def test_decode_datetime_aware_str(self, s):
+        sol = datetime.datetime.fromisoformat(s.replace("Z", "+00:00"))
+        msg = msgspec.msgpack.encode(s)
+        res = msgspec.msgpack.decode(msg, type=datetime.datetime)
+        assert sol == res
+
+    @pytest.mark.parametrize(
+        "s",
+        [
+            "1234-01-02T03:04:05",
+            "1234-01-02T03:04:05.123",
+            "1234-01-02T03:04:05.123456",
+        ],
+    )
+    def test_decode_datetime_naive(self, s):
+        sol = datetime.datetime.fromisoformat(s)
+        msg = msgspec.msgpack.encode(s)
+        res = msgspec.msgpack.decode(msg, type=datetime.datetime)
+        assert sol == res
+
     def test_datetime_unexpected_type(self):
         self.check_unexpected_type(datetime.datetime, 1, "Expected `datetime`")
         self.check_unexpected_type(
@@ -1165,8 +1189,8 @@ class TestTypedDecoder:
         [
             ([int, float], [1, 2.5]),
             (
-                [datetime.datetime, msgspec.msgpack.Ext, int, str],
-                [datetime.datetime.now(UTC), msgspec.msgpack.Ext(1, b"two"), 1, "two"],
+                [bytes, msgspec.msgpack.Ext, int, str],
+                [b"test", msgspec.msgpack.Ext(1, b"two"), 1, "two"],
             ),
             ([str, bytearray], ["three", bytearray(b"four")]),
             ([bool, None, float, str], [True, None, 1.5, "test"]),
diff --git a/tests/test_schema.py b/tests/test_schema.py
index f3fa12e..82df954 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -22,6 +22,7 @@ from typing import (
 import pytest
 
 import msgspec
+from msgspec import Meta
 from msgspec._utils import merge_json
 
 from utils import temp_module
@@ -85,9 +86,7 @@ def test_custom():
     schema = {"type": "string", "format": "decimal"}
 
     assert (
-        msgspec.json.schema(
-            Annotated[decimal.Decimal, msgspec.Meta(extra_json_schema=schema)]
-        )
+        msgspec.json.schema(Annotated[decimal.Decimal, Meta(extra_json_schema=schema)])
         == schema
     )
 
@@ -120,11 +119,17 @@ def test_binary(typ):
     }
 
 
-def test_datetime():
-    assert msgspec.json.schema(datetime.datetime) == {
-        "type": "string",
-        "format": "date-time",
-    }
+@pytest.mark.parametrize(
+    "get_type, extra",
+    [
+        (lambda: datetime.datetime, {}),
+        (lambda: Annotated[datetime.datetime, Meta(tz=None)], {}),
+        (lambda: Annotated[datetime.datetime, Meta(tz=True)], {"format": "date-time"}),
+        (lambda: Annotated[datetime.datetime, Meta(tz=False)], {}),
+    ],
+)
+def test_datetime(get_type, extra):
+    assert msgspec.json.schema(get_type()) == {"type": "string", **extra}
 
 
 def test_date():
@@ -716,7 +721,7 @@ def test_struct_array_union():
     ],
 )
 def test_numeric_metadata(field, constraint):
-    typ = Annotated[int, msgspec.Meta(**{field: 2})]
+    typ = Annotated[int, Meta(**{field: 2})]
     assert msgspec.json.schema(typ) == {"type": "integer", constraint: 2}
 
 
@@ -729,7 +734,7 @@ def test_numeric_metadata(field, constraint):
     ],
 )
 def test_string_metadata(field, val, constraint):
-    typ = Annotated[str, msgspec.Meta(**{field: val})]
+    typ = Annotated[str, Meta(**{field: val})]
     assert msgspec.json.schema(typ) == {"type": "string", constraint: val}
 
 
@@ -740,7 +745,7 @@ def test_string_metadata(field, val, constraint):
 )
 def test_binary_metadata(typ, field, n, constraint):
     n2 = len(b64encode(b"x" * n))
-    typ = Annotated[typ, msgspec.Meta(**{field: n})]
+    typ = Annotated[typ, Meta(**{field: n})]
     assert msgspec.json.schema(typ) == {
         "type": "string",
         constraint: n2,
@@ -754,7 +759,7 @@ def test_binary_metadata(typ, field, n, constraint):
     [("min_length", "minItems"), ("max_length", "maxItems")],
 )
 def test_array_metadata(typ, field, constraint):
-    typ = Annotated[typ, msgspec.Meta(**{field: 2})]
+    typ = Annotated[typ, Meta(**{field: 2})]
     assert msgspec.json.schema(typ) == {"type": "array", constraint: 2}
 
 
@@ -763,14 +768,14 @@ def test_array_metadata(typ, field, constraint):
     [("min_length", "minProperties"), ("max_length", "maxProperties")],
 )
 def test_object_metadata(field, constraint):
-    typ = Annotated[dict, msgspec.Meta(**{field: 2})]
+    typ = Annotated[dict, Meta(**{field: 2})]
     assert msgspec.json.schema(typ) == {"type": "object", constraint: 2}
 
 
 def test_generic_metadata():
     typ = Annotated[
         int,
-        msgspec.Meta(
+        Meta(
             title="the title",
             description="the description",
             examples=[1, 2, 3],