Repository: marshmallow-code/marshmallow

Issue:
DateTime timezone management rework
There are various issues I'd like to address in `DateTime` timezone management.

- ~Different behaviour depending on whether dateutil is installed (https://github.com/marshmallow-code/marshmallow/issues/497). Explicit would be better than implicit.~ (Fixed in #1265)
- Awareness not enforced (https://github.com/marshmallow-code/marshmallow/issues/520).

I begun work on this in #933 a while ago but I never got this through. I'd like to come up with a good design before getting back to it.

----------------------------------------------------------------------------------------

~AFAIU, the only action for which dateutil is required is deserializing iso formatted aware datetimes with non-utc timezones.~ Nope. dateutil is never needed (See below and #1265).

~Only iso requires dateutil. rfc format has timezones (https://tools.ietf.org/html/rfc822) but those are not as complex as iso timezones so they don't need dateutil.~ We don't use dateutil for RFC datetimes anymore (https://github.com/marshmallow-code/marshmallow/pull/1246 and https://github.com/marshmallow-code/marshmallow/pull/1248).

----------------------------------------------------------------------------------------

I've been thinking of using two field classes: `NaiveDateTime` and `AwareDateTime`.

Here's what they could do when deserializing:

Input | `NaiveDateTime` | `AwareDateTime`
-- | - | -
Naive | Pass | Fail
Aware UTC | Fail | Pass
Aware non UTC | Fail | Pass unless `utc_only` is `True`

We could add a `utc_only` flag to `AwareDateTime`. An exception would be raised at instantiation time if it is `False` and dateutil is not installed. (This may not be at import time if fields are declared programmatically, but it is better than deserialization time.) `utc_only` would default to `False`. Basically, this means the user who do not want to install dateutil would have to opt-in for `utc_only`. If it's too much pain, he could subclass the field to avoid providing the parameter on each instance.

----------------------------------------------------------------------------------------

There may be other aspects to consider:

- Could `NaiveDateTime` accept an aware datetime? Should it drop the TZ? I don't think so. Should it convert it to a specified TZ before dropping the TZ? It could accept a `timezone` argument specifying a target tz. An exception would be raised if target TZ is not UTC and dateutil is not installed.

`timezone` | Deserialization of aware datetimes in `NaiveDateTime`
-- | -
`None` | Fail
`'UTC'` | Convert to UTC then drop TZ
Some other TZ | Convert to UTC then drop TZ, raise at init if format is iso and no dateutil

- Could `AwareDateTime` accept a naive datetime? It could consider UTC by default. Or we could add a `default_tz` argument.

`default_tz` | Deserialization of naive datetimes in `AwareDateTime`
-- | -
`None` | Fail
`'UTC'` | Add UTC TZ
Some other TZ | Add TZ, raise at init if format is iso and no dateutil

Should this argument be used upon serialization as well? (We could also have `default_tz` and `missing_tz` for serialization/deserialization, copied on the `default`/`missing` logic, but I'd rather avoid this kind of complexity.)

- Could `AwareDateTime` provide a TZ conversion feature? We could add a `convert_to_tz` argument to specify a TZ to convert to (and raise if TZ not UTC and no dateutil)?

`convert_to_tz` | Post-deserialization of datetimes in `AwareDateTime`
-- | -
`None` | Keep TZ
`'UTC'` | Convert to UTC TZ
Some other TZ | Convert to TZ, raise at init if format is iso and no dateutil

Should this argument be used upon serialization as well? I'd say no. But maybe there's a use case for this. AFAIU, the point of `LocalDateTime` is to keep datetime TZ on serialization while `DateTime` converts to UTC implicitly.

- Should we keep `DateTime` as an alias? To which field and with which options?



Discussion:
sloria: Is `NaiveDateTime` necessary? What's the use case?
lafrech: In Python, naive and aware datetimes are different objects with different properties and some operations only work on either naive or aware datetimes. I was caught by this already (https://github.com/lepture/flask-oauthlib/issues/294). Being able to enforce the type is therefore a must have IMHO. Otherwise, the user needs to do it manually.

Whether it is done by a specific class or a flag is an implementation choice. I think different classes is better, more explicit. Generally we have a class for a type and the flags are just parameters.
sloria: It makes sense to have validation that an input datetime includes timezone info, but I'm more curious about cases when you want to enforce naiveness. Or is it more about having symmetry?
lafrech: Symmetry would be good enough a reason for me, since it is relatively easy to achieve.

[This issue](https://github.com/lepture/flask-oauthlib/issues/294) is an example of a lib that expects a naive datetime and will fail if fed an aware datetime. I don't use it anymore, but by the time, I had to enforce naiveness myself manually.

This issue can be extended to every use case involving date comparisons.

If the API stores datetimes, it most likely wants to enforce the type, if only to allow the user to sort objects by datetime. Perhaps most of the time, the DB driver will uniformize the format, by making a silent choice to force aware or naive, but I think this should be the role of the deserialization layer.

Maybe I'm missing something because once I fell into this, I figured it was something marshmallow absolutely needed to address, but since then, I heard no one complain, so maybe it is not such an issue in practice.

(In any case, I'd still consider this PR relevant, as it comes with a nice cleanup, hopefully no regression, and a code easier to maintain and with better test coverage.)
sloria: Sure, I'm fine with that reasoning and with going forward with this. Will take a closer look hopefully over the next few days.
deckar01: > I heard no one complain

I suspect loading datetimes is just not a very common behavior of most apps. Not until I made a webcam timelapse scheduler recently did I ever have to accept a datetime from the user.
lafrech: Rebased and documented.

Feedback welcome.

I'm open to changes, especially about the new parameter names. And the docs.

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 8e2fd474..a05acd32 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -4,6 +4,16 @@ Changelog
 3.0.0 (unreleased)
 ++++++++++++++++++
 
+Features:
+
+- *Backwards-incompatible*: ``DateTime`` does not affect timezone information
+  on serialization and deserialization.
+- Add ``NaiveDateTime`` and ``AwareDateTime`` to enforce timezone awareness.
+
+Deprecations/Removals:
+
+- ``LocalDateTime`` is removed.
+
 Bug fixes:
 
 - Fix behavior of `List(Nested("self"))` (`#779 (comment) <https://github.com/marshmallow-code/marshmallow/issues/779#issuecomment-396354987>`_).
@@ -23,7 +33,7 @@ Features:
 - Improve error messages for ``validate.Range``.
 - Use ``raise from exc`` for better stack traces (:pr:`1254`). Thanks
   :user:`fuhrysteve`.
-- python-dateutil is no longer used. This resolves the inconsistent behavior 
+- python-dateutil is no longer used. This resolves the inconsistent behavior
   based on the presence of python-dateutil (:issue:`497`, :issue:`1234`).
 
 Bug fixes:
diff --git a/docs/upgrading.rst b/docs/upgrading.rst
index b87b59dd..8a827dbd 100644
--- a/docs/upgrading.rst
+++ b/docs/upgrading.rst
@@ -788,6 +788,44 @@ The ``Meta`` option ``dateformat`` used to pass format to `DateTime <marshmallow
     MySchema().dump({"x": dt.datetime(2017, 9, 19), "y": dt.date(2017, 9, 19)})
     # => {{'x': '2017-09', 'y': '09-19'}}
 
+``DateTime`` leaves timezone information untouched during serialization
+***********************************************************************
+
+``DateTime`` does not convert naive datetimes to UTC on serialization and
+``LocalDateTime`` is removed.
+
+.. code-block:: python
+
+    # 2.x
+    class MySchema(Schema):
+        x = fields.DateTime()
+        y = fields.DateTime()
+        z = fields.LocalDateTime()
+
+
+    MySchema().dump(
+        {
+            "x": dt.datetime(2017, 9, 19),
+            "y": dt.datetime(2017, 9, 19, tzinfo=dt.timezone(dt.timedelta(hours=2))),
+            "z": dt.datetime(2017, 9, 19, tzinfo=dt.timezone(dt.timedelta(hours=2))),
+        }
+    )
+    # => {{'x': '2017-09-19T00:00:00+00:00', 'y': '2017-09-18T22:00:00+00:00', 'z': '2017-09-19T00:00:00+02:00'}}
+
+    # 3.x
+    class MySchema(Schema):
+        x = fields.DateTime()
+        y = fields.DateTime()
+
+
+    MySchema().dump(
+        {
+            "x": dt.datetime(2017, 9, 19),
+            "y": dt.datetime(2017, 9, 19, tzinfo=dt.timezone(dt.timedelta(hours=2))),
+        }
+    )
+    # => {{'x': '2017-09-19T00:00:00', 'y': '2017-09-19T00:00:00+02:00'}}
+
 The ``prefix`` ``Schema`` parameter is removed
 **********************************************
 
diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py
index 69f7f2d0..e48a5305 100644
--- a/src/marshmallow/fields.py
+++ b/src/marshmallow/fields.py
@@ -11,7 +11,12 @@ from collections.abc import Mapping as _Mapping
 
 from marshmallow import validate, utils, class_registry
 from marshmallow.base import FieldABC, SchemaABC
-from marshmallow.utils import is_collection, missing as missing_, resolve_field_instance
+from marshmallow.utils import (
+    is_collection,
+    missing as missing_,
+    resolve_field_instance,
+    is_aware,
+)
 from marshmallow.exceptions import (
     ValidationError,
     StringNotCollectionError,
@@ -35,7 +40,8 @@ __all__ = [
     "Boolean",
     "Float",
     "DateTime",
-    "LocalDateTime",
+    "NaiveDateTime",
+    "AwareDateTime",
     "Time",
     "Date",
     "TimeDelta",
@@ -1027,19 +1033,13 @@ class Boolean(Field):
 
 
 class DateTime(Field):
-    """A formatted datetime string in UTC.
+    """A formatted datetime string.
 
     Example: ``'2014-12-22T03:12:58.019077+00:00'``
 
-    Timezone-naive `datetime` objects are converted to
-    UTC (+00:00) by :meth:`Schema.dump <marshmallow.Schema.dump>`.
-    :meth:`Schema.load <marshmallow.Schema.load>` returns `datetime`
-    objects that are timezone-aware.
-
     :param str format: Either ``"rfc"`` (for RFC822), ``"iso"`` (for ISO8601),
         or a date format string. If `None`, defaults to "iso".
     :param kwargs: The same keyword arguments that :class:`Field` receives.
-
     """
 
     SERIALIZATION_FUNCS = {
@@ -1062,9 +1062,9 @@ class DateTime(Field):
 
     SCHEMA_OPTS_VAR_NAME = "datetimeformat"
 
-    localtime = False
     default_error_messages = {
         "invalid": "Not a valid {obj_type}.",
+        "invalid_awareness": "Not a valid {awareness} {obj_type}.",
         "format": '"{input}" cannot be formatted as a {obj_type}.',
     }
 
@@ -1090,7 +1090,7 @@ class DateTime(Field):
         format_func = self.SERIALIZATION_FUNCS.get(data_format)
         if format_func:
             try:
-                return format_func(value, localtime=self.localtime)
+                return format_func(value)
             except (TypeError, AttributeError, ValueError):
                 self.fail("format", input=value, obj_type=self.OBJ_TYPE)
         else:
@@ -1121,15 +1121,63 @@ class DateTime(Field):
         return dt.datetime.strptime(value, data_format)
 
 
-class LocalDateTime(DateTime):
-    """A formatted datetime string in localized time, relative to UTC.
+class NaiveDateTime(DateTime):
+    """A formatted naive datetime string.
+
+    :param str format: See :class:`DateTime`.
+    :param timezone timezone: Used on deserialization. If `None`,
+        aware datetimes are rejected. If not `None`, aware datetimes are
+        converted to this timezone before their timezone information is
+        removed.
+    :param kwargs: The same keyword arguments that :class:`Field` receives.
+    """
+
+    AWARENESS = "naive"
 
-        ex. ``"Sun, 10 Nov 2013 08:23:45 -0600"``
+    def __init__(self, format=None, *, timezone=None, **kwargs):
+        super().__init__(format=format, **kwargs)
+        self.timezone = timezone
 
-    Takes the same arguments as :class:`DateTime <marshmallow.fields.DateTime>`.
+    def _deserialize(self, value, attr, data, **kwargs):
+        ret = super()._deserialize(value, attr, data, **kwargs)
+        if is_aware(ret):
+            if self.timezone is None:
+                self.fail(
+                    "invalid_awareness",
+                    awareness=self.AWARENESS,
+                    obj_type=self.OBJ_TYPE,
+                )
+            ret = ret.astimezone(self.timezone).replace(tzinfo=None)
+        return ret
+
+
+class AwareDateTime(DateTime):
+    """A formatted aware datetime string.
+
+    :param str format: See :class:`DateTime`.
+    :param timezone default_timezone: Used on deserialization. If `None`, naive
+        datetimes are rejected. If not `None`, naive datetimes are set this
+        timezone.
+    :param kwargs: The same keyword arguments that :class:`Field` receives.
     """
 
-    localtime = True
+    AWARENESS = "aware"
+
+    def __init__(self, format=None, *, default_timezone=None, **kwargs):
+        super().__init__(format=format, **kwargs)
+        self.default_timezone = default_timezone
+
+    def _deserialize(self, value, attr, data, **kwargs):
+        ret = super()._deserialize(value, attr, data, **kwargs)
+        if not is_aware(ret):
+            if self.default_timezone is None:
+                self.fail(
+                    "invalid_awareness",
+                    awareness=self.AWARENESS,
+                    obj_type=self.OBJ_TYPE,
+                )
+            ret = ret.replace(tzinfo=self.default_timezone)
+        return ret
 
 
 class Time(Field):
diff --git a/src/marshmallow/utils.py b/src/marshmallow/utils.py
index 7ee9e1f4..ce5d876e 100644
--- a/src/marshmallow/utils.py
+++ b/src/marshmallow/utils.py
@@ -137,6 +137,13 @@ class UTC(dt.tzinfo):
 UTC = utc = UTC()  # UTC is a singleton
 
 
+# https://stackoverflow.com/a/27596917
+def is_aware(datetime):
+    return (
+        datetime.tzinfo is not None and datetime.tzinfo.utcoffset(datetime) is not None
+    )
+
+
 def from_rfc(datestring):
     """Parse a RFC822-formatted datetime string and return a datetime object.
 
@@ -145,19 +152,11 @@ def from_rfc(datestring):
     return parsedate_to_datetime(datestring)
 
 
-def rfcformat(datetime, *, localtime=False):
+def rfcformat(datetime):
     """Return the RFC822-formatted representation of a datetime object.
 
     :param datetime datetime: The datetime.
-    :param bool localtime: If ``True``, return the date relative to the local
-        timezone instead of UTC, displaying the proper offset,
-        e.g. "Sun, 10 Nov 2013 08:23:45 -0600"
     """
-    if localtime and datetime.tzinfo is None:
-        datetime = UTC.localize(datetime)
-    if not localtime and datetime.tzinfo is not None:
-        # Remove timezone to format as "-0000" rather than "+0000"
-        datetime = datetime.astimezone(UTC).replace(tzinfo=None)
     return format_datetime(datetime)
 
 
@@ -236,19 +235,15 @@ def from_iso_date(value):
     return dt.date(**kw)
 
 
-def isoformat(datetime, *args, localtime=False, **kwargs):
-    """Return the ISO8601-formatted UTC representation of a datetime object."""
-    if localtime and datetime.tzinfo is not None:
-        localized = datetime
-    else:
-        if datetime.tzinfo is None:
-            localized = UTC.localize(datetime)
-        else:
-            localized = datetime.astimezone(UTC)
-    return localized.isoformat(*args, **kwargs)
+def isoformat(datetime):
+    """Return the ISO8601-formatted representation of a datetime object.
+
+    :param datetime datetime: The datetime.
+    """
+    return datetime.isoformat()
 
 
-def to_iso_date(date, *args, **kwargs):
+def to_iso_date(date):
     return dt.date.isoformat(date)
 
 


Test patch:
diff --git a/tests/base.py b/tests/base.py
index 4c3a9466..e502a28e 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -19,7 +19,6 @@ ALL_FIELDS = [
     fields.Float,
     fields.Number,
     fields.DateTime,
-    fields.LocalDateTime,
     fields.Time,
     fields.Date,
     fields.TimeDelta,
@@ -39,14 +38,6 @@ def assert_date_equal(d1, d2):
     assert d1.day == d2.day
 
 
-def assert_datetime_equal(dt1, dt2):
-    assert_date_equal(dt1, dt2)
-    assert dt1.hour == dt2.hour
-    assert dt1.minute == dt2.minute
-    assert dt1.second == dt2.second
-    assert dt1.microsecond == dt2.microsecond
-
-
 def assert_time_equal(t1, t2):
     assert t1.hour == t2.hour
     assert t1.minute == t2.minute
@@ -163,7 +154,6 @@ class UserSchema(Schema):
     )
     created_iso = fields.DateTime(format="iso", attribute="created", dump_only=True)
     updated = fields.DateTime()
-    updated_local = fields.LocalDateTime(attribute="updated", dump_only=True)
     species = fields.String(attribute="SPECIES")
     id = fields.String(default="no-id")
     uppername = Uppercased(attribute="name", dump_only=True)
@@ -211,7 +201,6 @@ class UserMetaSchema(Schema):
     balance = fields.Decimal()
     is_old = fields.Method("get_is_old")
     lowername = fields.Function(get_lowername)
-    updated_local = fields.LocalDateTime(attribute="updated", dump_only=True)
     species = fields.String(attribute="SPECIES")
     homepage = fields.Url()
     email = fields.Email()
@@ -242,7 +231,6 @@ class UserMetaSchema(Schema):
             "balance",
             "is_old",
             "lowername",
-            "updated_local",
             "species",
             "registered",
             "hair_colors",
diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py
index e9264e64..3caad6b4 100644
--- a/tests/test_deserialization.py
+++ b/tests/test_deserialization.py
@@ -9,13 +9,7 @@ from marshmallow import EXCLUDE, INCLUDE, RAISE, fields, utils, Schema, validate
 from marshmallow.exceptions import ValidationError
 from marshmallow.validate import Equal
 
-from tests.base import (
-    assert_date_equal,
-    assert_datetime_equal,
-    assert_time_equal,
-    central,
-    ALL_FIELDS,
-)
+from tests.base import assert_date_equal, assert_time_equal, central, ALL_FIELDS
 
 
 class TestDeserializingNone:
@@ -399,6 +393,7 @@ class TestFieldDeserialization:
             42,
             "",
             [],
+            "2018",
             "2018-01-01",
             dt.datetime.now().strftime("%H:%M:%S %Y-%m-%d"),
             dt.datetime.now().strftime("%m-%d-%Y %H:%M:%S"),
@@ -409,48 +404,160 @@ class TestFieldDeserialization:
         with pytest.raises(ValidationError, match="Not a valid datetime."):
             field.deserialize(in_value)
 
-    def test_datetime_passed_year_is_invalid(self):
-        field = fields.DateTime()
-        with pytest.raises(ValidationError):
-            field.deserialize("1916")
-
-    def test_datetime_passed_date_is_invalid(self):
-        field = fields.DateTime()
-        with pytest.raises(ValidationError):
-            field.deserialize("2017-04-13")
-
     def test_custom_date_format_datetime_field_deserialization(self):
-        dtime = dt.datetime.now()
-        datestring = dtime.strftime("%H:%M:%S.%f %Y-%m-%d")
+        # Datetime string with format "%H:%M:%S.%f %Y-%m-%d"
+        datestring = "10:11:12.123456 2019-01-02"
 
+        # Deserialization should fail when datestring is not of same format
         field = fields.DateTime(format="%d-%m-%Y %H:%M:%S")
-        # deserialization should fail when datestring is not of same format
         with pytest.raises(ValidationError, match="Not a valid datetime."):
             field.deserialize(datestring)
+
         field = fields.DateTime(format="%H:%M:%S.%f %Y-%m-%d")
-        assert_datetime_equal(field.deserialize(datestring), dtime)
+        assert field.deserialize(datestring) == dt.datetime(
+            2019, 1, 2, 10, 11, 12, 123456
+        )
+
+        field = fields.NaiveDateTime(format="%H:%M:%S.%f %Y-%m-%d")
+        assert field.deserialize(datestring) == dt.datetime(
+            2019, 1, 2, 10, 11, 12, 123456
+        )
+
+        field = fields.AwareDateTime(format="%H:%M:%S.%f %Y-%m-%d")
+        with pytest.raises(ValidationError, match="Not a valid aware datetime."):
+            field.deserialize(datestring)
 
     @pytest.mark.parametrize("fmt", ["rfc", "rfc822"])
-    def test_rfc_datetime_field_deserialization(self, fmt):
-        dtime = dt.datetime.now().replace(microsecond=0)
-        datestring = utils.rfcformat(dtime)
+    @pytest.mark.parametrize(
+        ("value", "expected", "aware"),
+        [
+            (
+                "Sun, 10 Nov 2013 01:23:45 -0000",
+                dt.datetime(2013, 11, 10, 1, 23, 45),
+                False,
+            ),
+            (
+                "Sun, 10 Nov 2013 01:23:45 +0000",
+                utils.UTC.localize(dt.datetime(2013, 11, 10, 1, 23, 45)),
+                True,
+            ),
+            (
+                "Sun, 10 Nov 2013 01:23:45 -0600",
+                central.localize(dt.datetime(2013, 11, 10, 1, 23, 45), is_dst=False),
+                True,
+            ),
+        ],
+    )
+    def test_rfc_datetime_field_deserialization(self, fmt, value, expected, aware):
         field = fields.DateTime(format=fmt)
-        assert_datetime_equal(field.deserialize(datestring), dtime)
+        assert field.deserialize(value) == expected
+        field = fields.NaiveDateTime(format=fmt)
+        if aware:
+            with pytest.raises(ValidationError, match="Not a valid naive datetime."):
+                field.deserialize(value)
+        else:
+            assert field.deserialize(value) == expected
+        field = fields.AwareDateTime(format=fmt)
+        if not aware:
+            with pytest.raises(ValidationError, match="Not a valid aware datetime."):
+                field.deserialize(value)
+        else:
+            assert field.deserialize(value) == expected
 
     @pytest.mark.parametrize("fmt", ["iso", "iso8601"])
-    def test_iso_datetime_field_deserialization(self, fmt):
-        dtime = dt.datetime.now()
-        datestring = dtime.isoformat()
+    @pytest.mark.parametrize(
+        ("value", "expected", "aware"),
+        [
+            ("2013-11-10T01:23:45", dt.datetime(2013, 11, 10, 1, 23, 45), False),
+            (
+                "2013-11-10T01:23:45+00:00",
+                utils.UTC.localize(dt.datetime(2013, 11, 10, 1, 23, 45)),
+                True,
+            ),
+            (
+                # Regression test for https://github.com/marshmallow-code/marshmallow/issues/1251
+                "2013-11-10T01:23:45.123+00:00",
+                utils.UTC.localize(dt.datetime(2013, 11, 10, 1, 23, 45, 123000)),
+                True,
+            ),
+            (
+                "2013-11-10T01:23:45.123456+00:00",
+                utils.UTC.localize(dt.datetime(2013, 11, 10, 1, 23, 45, 123456)),
+                True,
+            ),
+            (
+                "2013-11-10T01:23:45-06:00",
+                central.localize(dt.datetime(2013, 11, 10, 1, 23, 45), is_dst=False),
+                True,
+            ),
+        ],
+    )
+    def test_iso_datetime_field_deserialization(self, fmt, value, expected, aware):
         field = fields.DateTime(format=fmt)
-        assert_datetime_equal(field.deserialize(datestring), dtime)
+        assert field.deserialize(value) == expected
+        field = fields.NaiveDateTime(format=fmt)
+        if aware:
+            with pytest.raises(ValidationError, match="Not a valid naive datetime."):
+                field.deserialize(value)
+        else:
+            assert field.deserialize(value) == expected
+        field = fields.AwareDateTime(format=fmt)
+        if not aware:
+            with pytest.raises(ValidationError, match="Not a valid aware datetime."):
+                field.deserialize(value)
+        else:
+            assert field.deserialize(value) == expected
 
-    def test_localdatetime_field_deserialization(self):
-        dtime = dt.datetime.now()
-        localized_dtime = central.localize(dtime)
-        field = fields.DateTime(format="iso")
-        result = field.deserialize(localized_dtime.isoformat())
-        assert_datetime_equal(result, dtime)
-        assert result.tzinfo is not None
+    @pytest.mark.parametrize(
+        ("fmt", "timezone", "value", "expected"),
+        [
+            ("iso", None, "2013-11-10T01:23:45", dt.datetime(2013, 11, 10, 1, 23, 45)),
+            (
+                "iso",
+                utils.UTC,
+                "2013-11-10T01:23:45+00:00",
+                dt.datetime(2013, 11, 10, 1, 23, 45),
+            ),
+            (
+                "iso",
+                central,
+                "2013-11-10T01:23:45-03:00",
+                dt.datetime(2013, 11, 9, 22, 23, 45),
+            ),
+            (
+                "rfc",
+                None,
+                "Sun, 10 Nov 2013 01:23:45 -0000",
+                dt.datetime(2013, 11, 10, 1, 23, 45),
+            ),
+            (
+                "rfc",
+                utils.UTC,
+                "Sun, 10 Nov 2013 01:23:45 +0000",
+                dt.datetime(2013, 11, 10, 1, 23, 45),
+            ),
+            (
+                "rfc",
+                central,
+                "Sun, 10 Nov 2013 01:23:45 -0300",
+                dt.datetime(2013, 11, 9, 22, 23, 45),
+            ),
+        ],
+    )
+    def test_naive_datetime_with_timezone(self, fmt, timezone, value, expected):
+        field = fields.NaiveDateTime(format=fmt, timezone=timezone)
+        assert field.deserialize(value) == expected
+
+    @pytest.mark.parametrize("timezone", (utils.UTC, central))
+    @pytest.mark.parametrize(
+        ("fmt", "value"),
+        [("iso", "2013-11-10T01:23:45"), ("rfc", "Sun, 10 Nov 2013 01:23:45")],
+    )
+    def test_aware_datetime_default_timezone(self, fmt, timezone, value):
+        field = fields.AwareDateTime(format=fmt, default_timezone=timezone)
+        assert field.deserialize(value) == dt.datetime(
+            2013, 11, 10, 1, 23, 45, tzinfo=timezone
+        )
 
     def test_time_field_deserialization(self):
         field = fields.Time()
diff --git a/tests/test_schema.py b/tests/test_schema.py
index d41a5933..df4e940c 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -569,11 +569,6 @@ def test_tz_datetime_field(user, serialized_user):
     assert serialized_user["updated"] == expected
 
 
-def test_local_datetime_field(user, serialized_user):
-    expected = utils.isoformat(user.updated, localtime=True)
-    assert serialized_user["updated_local"] == expected
-
-
 def test_class_variable(serialized_user):
     assert serialized_user["species"] == "Homo sapiens"
 
@@ -1636,7 +1631,6 @@ def test_meta_serializer_fields():
     assert result["uppername"] == "JOHN"
     assert result["is_old"] is False
     assert result["created"] == utils.isoformat(u.created)
-    assert result["updated_local"] == utils.isoformat(u.updated, localtime=True)
     assert result["finger_count"] == 10
     assert result["various_data"] == dict(u.various_data)
 
@@ -1645,7 +1639,6 @@ def test_meta_fields_mapping(user):
     s = UserMetaSchema()
     s.dump(user)  # need to call dump to update fields
     assert type(s.fields["balance"]) == fields.Decimal
-    assert type(s.fields["updated_local"]) == fields.LocalDateTime
     # Inferred fields
     assert type(s.fields["name"]._field_cache[fields.String]) == fields.String
     assert type(s.fields["created"]._field_cache[fields.DateTime]) == fields.DateTime
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index 6d24c6d6..c0793b75 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -11,7 +11,7 @@ import pytest
 from marshmallow import Schema, fields, utils, missing as missing_
 from marshmallow.exceptions import ValidationError
 
-from tests.base import User, ALL_FIELDS
+from tests.base import User, ALL_FIELDS, central
 
 
 class DateTimeList:
@@ -498,11 +498,6 @@ class TestFieldSerialization:
             "Years": 999,
         }
 
-    def test_datetime_serializes_to_iso_by_default(self, user):
-        field = fields.DateTime()  # No format specified
-        expected = utils.isoformat(user.created, localtime=False)
-        assert field.serialize("created", user) == expected
-
     @pytest.mark.parametrize("value", ["invalid", [], 24])
     def test_datetime_invalid_serialization(self, value, user):
         field = fields.DateTime()
@@ -515,28 +510,52 @@ class TestFieldSerialization:
         ] == '"{}" cannot be formatted as a datetime.'.format(value)
 
     @pytest.mark.parametrize("fmt", ["rfc", "rfc822"])
-    def test_datetime_field_rfc822(self, fmt, user):
-        field = fields.DateTime(format=fmt)
-        expected = utils.rfcformat(user.created, localtime=False)
-        assert field.serialize("created", user) == expected
-
-    def test_localdatetime_rfc_field(self, user):
-        field = fields.LocalDateTime(format="rfc")
-        expected = utils.rfcformat(user.created, localtime=True)
-        assert field.serialize("created", user) == expected
-
-    @pytest.mark.parametrize("fmt", ["iso", "iso8601"])
-    def test_datetime_iso8601(self, fmt, user):
+    @pytest.mark.parametrize(
+        ("value", "expected"),
+        [
+            (dt.datetime(2013, 11, 10, 1, 23, 45), "Sun, 10 Nov 2013 01:23:45 -0000"),
+            (
+                utils.UTC.localize(dt.datetime(2013, 11, 10, 1, 23, 45)),
+                "Sun, 10 Nov 2013 01:23:45 +0000",
+            ),
+            (
+                central.localize(dt.datetime(2013, 11, 10, 1, 23, 45), is_dst=False),
+                "Sun, 10 Nov 2013 01:23:45 -0600",
+            ),
+        ],
+    )
+    def test_datetime_field_rfc822(self, fmt, value, expected):
         field = fields.DateTime(format=fmt)
-        expected = utils.isoformat(user.created, localtime=False)
-        assert field.serialize("created", user) == expected
+        assert field.serialize("d", {"d": value}) == expected
 
-    def test_localdatetime_iso(self, user):
-        field = fields.LocalDateTime(format="iso")
-        expected = utils.isoformat(user.created, localtime=True)
-        assert field.serialize("created", user) == expected
+    @pytest.mark.parametrize("fmt", ["iso", "iso8601", None])
+    @pytest.mark.parametrize(
+        ("value", "expected"),
+        [
+            (dt.datetime(2013, 11, 10, 1, 23, 45), "2013-11-10T01:23:45"),
+            (
+                utils.UTC.localize(dt.datetime(2013, 11, 10, 1, 23, 45, 123456)),
+                "2013-11-10T01:23:45.123456+00:00",
+            ),
+            (
+                utils.UTC.localize(dt.datetime(2013, 11, 10, 1, 23, 45)),
+                "2013-11-10T01:23:45+00:00",
+            ),
+            (
+                central.localize(dt.datetime(2013, 11, 10, 1, 23, 45), is_dst=False),
+                "2013-11-10T01:23:45-06:00",
+            ),
+        ],
+    )
+    def test_datetime_field_iso8601(self, fmt, value, expected):
+        if fmt is None:
+            # Test default is ISO
+            field = fields.DateTime()
+        else:
+            field = fields.DateTime(format=fmt)
+        assert field.serialize("d", {"d": value}) == expected
 
-    def test_datetime_format(self, user):
+    def test_datetime_field_format(self, user):
         format = "%Y-%m-%d"
         field = fields.DateTime(format=format)
         assert field.serialize("created", user) == user.created.strftime(format)
@@ -851,7 +870,7 @@ class TestSchemaSerialization:
         data = {"name": "Mick"}
         result = AliasingUserSerializer().dump(data)
         assert result["name"] == "Mick"
-        assert result["birthdate"] == "2017-09-29T00:00:00+00:00"
+        assert result["birthdate"] == "2017-09-29T00:00:00"
 
     def test_serialize_with_missing_param_callable(self):
         class AliasingUserSerializer(Schema):
@@ -861,7 +880,7 @@ class TestSchemaSerialization:
         data = {"name": "Mick"}
         result = AliasingUserSerializer().dump(data)
         assert result["name"] == "Mick"
-        assert result["birthdate"] == "2017-09-29T00:00:00+00:00"
+        assert result["birthdate"] == "2017-09-29T00:00:00"
 
 
 def test_serializing_named_tuple():
diff --git a/tests/test_utils.py b/tests/test_utils.py
index b688d23a..e211b1f6 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -6,12 +6,7 @@ from copy import copy, deepcopy
 import pytest
 
 from marshmallow import utils, fields, Schema
-from tests.base import (
-    assert_datetime_equal,
-    central,
-    assert_time_equal,
-    assert_date_equal,
-)
+from tests.base import central, assert_time_equal, assert_date_equal
 
 
 def test_missing_singleton_copy():
@@ -120,73 +115,93 @@ def test_is_collection():
     assert utils.is_collection({"foo": "bar"}) is False
 
 
-def test_rfcformat_gmt_naive():
-    d = dt.datetime(2013, 11, 10, 1, 23, 45)
-    assert utils.rfcformat(d) == "Sun, 10 Nov 2013 01:23:45 -0000"
-
-
-def test_rfcformat_central():
-    d = central.localize(dt.datetime(2013, 11, 10, 1, 23, 45), is_dst=False)
-    assert utils.rfcformat(d) == "Sun, 10 Nov 2013 07:23:45 -0000"
-
-
-def test_rfcformat_naive_localized():
-    d = dt.datetime(2013, 11, 10, 1, 23, 45)
-    assert utils.rfcformat(d, localtime=True) == "Sun, 10 Nov 2013 01:23:45 +0000"
-
-
-def test_rfcformat_central_localized():
-    d = central.localize(dt.datetime(2013, 11, 10, 8, 23, 45), is_dst=False)
-    assert utils.rfcformat(d, localtime=True) == "Sun, 10 Nov 2013 08:23:45 -0600"
-
-
-def test_isoformat():
-    d = dt.datetime(2013, 11, 10, 1, 23, 45)
-    assert utils.isoformat(d) == "2013-11-10T01:23:45+00:00"
-
-
-def test_isoformat_tzaware():
-    d = central.localize(dt.datetime(2013, 11, 10, 1, 23, 45), is_dst=False)
-    assert utils.isoformat(d) == "2013-11-10T07:23:45+00:00"
+@pytest.mark.parametrize(
+    ("value", "expected"),
+    [
+        (dt.datetime(2013, 11, 10, 1, 23, 45), "Sun, 10 Nov 2013 01:23:45 -0000"),
+        (
+            utils.UTC.localize(dt.datetime(2013, 11, 10, 1, 23, 45)),
+            "Sun, 10 Nov 2013 01:23:45 +0000",
+        ),
+        (
+            central.localize(dt.datetime(2013, 11, 10, 1, 23, 45), is_dst=False),
+            "Sun, 10 Nov 2013 01:23:45 -0600",
+        ),
+    ],
+)
+def test_rfc_format(value, expected):
+    assert utils.rfcformat(value) == expected
 
 
-def test_isoformat_localtime():
-    d = central.localize(dt.datetime(2013, 11, 10, 1, 23, 45), is_dst=False)
-    assert utils.isoformat(d, localtime=True) == "2013-11-10T01:23:45-06:00"
+@pytest.mark.parametrize(
+    ("value", "expected"),
+    [
+        (dt.datetime(2013, 11, 10, 1, 23, 45), "2013-11-10T01:23:45"),
+        (
+            utils.UTC.localize(dt.datetime(2013, 11, 10, 1, 23, 45, 123456)),
+            "2013-11-10T01:23:45.123456+00:00",
+        ),
+        (
+            utils.UTC.localize(dt.datetime(2013, 11, 10, 1, 23, 45)),
+            "2013-11-10T01:23:45+00:00",
+        ),
+        (
+            central.localize(dt.datetime(2013, 11, 10, 1, 23, 45), is_dst=False),
+            "2013-11-10T01:23:45-06:00",
+        ),
+    ],
+)
+def test_isoformat(value, expected):
+    assert utils.isoformat(value) == expected
 
 
-def test_from_rfc():
-    d = dt.datetime.now().replace(microsecond=0)
-    rfc = utils.rfcformat(d)
-    result = utils.from_rfc(rfc)
+@pytest.mark.parametrize(
+    ("value", "expected"),
+    [
+        ("Sun, 10 Nov 2013 01:23:45 -0000", dt.datetime(2013, 11, 10, 1, 23, 45)),
+        (
+            "Sun, 10 Nov 2013 01:23:45 +0000",
+            utils.UTC.localize(dt.datetime(2013, 11, 10, 1, 23, 45)),
+        ),
+        (
+            "Sun, 10 Nov 2013 01:23:45 -0600",
+            central.localize(dt.datetime(2013, 11, 10, 1, 23, 45), is_dst=False),
+        ),
+    ],
+)
+def test_from_rfc(value, expected):
+    result = utils.from_rfc(value)
     assert type(result) == dt.datetime
-    assert_datetime_equal(result, d)
+    assert result == expected
 
 
-@pytest.mark.parametrize("timezone", [None, central])
-def test_from_iso_datetime(timezone):
-    d = dt.datetime.now(tz=timezone)
-    formatted = d.isoformat()
-    result = utils.from_iso_datetime(formatted)
-    assert type(result) == dt.datetime
-    assert_datetime_equal(result, d)
-
-    # Test with 3-digit only microseconds
-    #  Regression test for https://github.com/marshmallow-code/marshmallow/issues/1251
-    d = dt.datetime.now(tz=timezone).replace(microsecond=123000)
-    formatted = d.isoformat()
-    formatted = formatted[:23] + formatted[26:]
-    result = utils.from_iso_datetime(formatted)
+@pytest.mark.parametrize(
+    ("value", "expected"),
+    [
+        ("2013-11-10T01:23:45", dt.datetime(2013, 11, 10, 1, 23, 45)),
+        (
+            "2013-11-10T01:23:45+00:00",
+            utils.UTC.localize(dt.datetime(2013, 11, 10, 1, 23, 45)),
+        ),
+        (
+            # Regression test for https://github.com/marshmallow-code/marshmallow/issues/1251
+            "2013-11-10T01:23:45.123+00:00",
+            utils.UTC.localize(dt.datetime(2013, 11, 10, 1, 23, 45, 123000)),
+        ),
+        (
+            "2013-11-10T01:23:45.123456+00:00",
+            utils.UTC.localize(dt.datetime(2013, 11, 10, 1, 23, 45, 123456)),
+        ),
+        (
+            "2013-11-10T01:23:45-06:00",
+            central.localize(dt.datetime(2013, 11, 10, 1, 23, 45), is_dst=False),
+        ),
+    ],
+)
+def test_from_iso_datetime(value, expected):
+    result = utils.from_iso_datetime(value)
     assert type(result) == dt.datetime
-    assert_datetime_equal(result, d)
-
-
-def test_from_iso_with_tz():
-    d = central.localize(dt.datetime.now())
-    formatted = d.isoformat()
-    result = utils.from_iso_datetime(formatted)
-    assert_datetime_equal(result, d)
-    assert result.tzinfo is not None
+    assert result == expected
 
 
 def test_from_iso_time_with_microseconds():