Repository: marshmallow-code/marshmallow
Pull request #2654 (2024-12-10 12:06:00)

Remove TimeDelta `serialization_type` parameter; deserialize from float; improve serialization perf and accuracy
This PR allows propagating floats/strings with decimals to TimeDelta.deserialize without silently downcasting the input to int. Currently, `serialization_type` is not only being used for serialization, but also being used for deserialization, which adds no added value: a float cast can always safely be used for deserialization and is backwards compatible (but fixes this bug).

originally reported here: https://github.com/sloria/environs/issues/372

MRE:
```pycon
>>> from marshmallow import fields
>>> field = fields.TimeDelta()
>>> field.deserialize(12.9)
datetime.timedelta(seconds=12)
```
After this PR:
```pycon
>>> from marshmallow import fields
>>> field = fields.TimeDelta()
>>> field.deserialize(12.9)
datetime.timedelta(seconds=12, microseconds=900000)
```

I added one test for string input, and changed the existing test for float input.

There are already `math.isclose` tests when passing floats to microseconds (which will be rounded by the datetime.timedelta constructor).

ddelange: Hi @lafrech 👋 

You're right, the docstring needed some work. Please see the last commit. I hope that also clarifies the bug that this PR fixes: needlessly using `serialization_type` during deserialization.
sloria: Docs update looks good to me. Thanks @ddelange .

Thanks for digging up the original issue and PRs @lafrech. Looking back at those, I think allowing truncation to an integer was an oversight on my part rather than an explicit decision. Even the [ISO8601](https://en.wikipedia.org/wiki/ISO_8601#Durations) spec mentioned in https://github.com/marshmallow-code/marshmallow/issues/105#issuecomment-66910264 allows for fractional components:

> The smallest value used may also have a decimal fraction,[[39]](https://en.wikipedia.org/wiki/ISO_8601#cite_note-39) as in "P0.5Y" to indicate half a year. This decimal fraction may be specified with either a [comma](https://en.wikipedia.org/wiki/Comma) or a [full stop](https://en.wikipedia.org/wiki/Full_stop), as in "P0,5Y" or "P0.5Y".

@lafrech @deckar01  Do we consider this a breaking change? Even though this is a much smaller change than previous major version bumps, I'm not opposed to a major version bump with some explanatory text in the changelog that says that the change is very limited in scope and only affects users of the `TimeDelta` field.
ddelange: fwiw, this change only affects (helps) users that were serializing using `serialization_type=float`, but then not taking care when deserializing.

I would say this does not warrant a major version bump, but rather a bugfix release.
ddelange: right. the good ol' _rely on a bug_ motto. [programming sucks](https://www.stilldrinking.org/programming-sucks)...
sloria: lol, yeah... i guess i'm not 100% sure it's a bug, given that there was an explicit test for the current behavior. i'm like 80% sure it was oversight, but it was long ago so want to give a bit of time for others to chime in before shipping the change
ddelange: Thanks for your elaborate review!

> From this perspective, I'm not sure the latter is less surprising behaviour.

To me the latter definitely looks 'right'. From another perspective: that float came from marshmallow (starting off with a timedelta). It got serialized and sent elsewhere for deserialization. When the receiving party deserializes, in the latter example he doesn't need prior knowledge about the sender's settings. Deserialization has now become robust.
ddelange: re: anything wrong - my argument here is that serialization_type should be used only for serialization and not for deserialization. but that's about it :)
lafrech: So would it be fine if the parameter was named `serialization_deserialization_type`, `de_serialization_type` or `serialized_type` of whatever we can come up with that makes it explicit it works both ways?

If it is just that, we could use a better naming and deprecate the old one.
ddelange: from my side there's no need for a breaking change here, this change just seemed like a nice robustness improvement to me (not considering this breaking).

it only came up because a user was surprised that his float was being silently truncated when feeding into @sloria's environs package. since that package only ever deserializes (values from unknown sources), this seemed like the right way forward. but feel free to close / take over if you do consider this a 'bad' change, no hard feelings :)

will look uglier than [now](https://github.com/sloria/environs/blob/11.2.1/src/environs/__init__.py#L377), but environs _could_ fix this without any PR om marshmallow by subclassing `TimeDelta.__init__`
sloria: I generally agree with @ddelange's comment in https://github.com/marshmallow-code/marshmallow/pull/2654#issuecomment-2537447810, and the proposed change certainly makes sense for the use case in environs. On one hand, we might as well be robust on input. `Boolean` will accept various strings and integers while outputting to a `bool`. On the other hand, marshmallow doesn't always attempt to be lenient on inputs. For example, `Date` and `DateTime` assume that the input and output formats are the same.

@lafrech @deckar01 Any strong opinions?
sloria: Another possible path would be to

1. Add a `deserialization_type` to this PR and make it default to `int`
2. Add a warning if a deserializing a float

something like

```diff
diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py
index b564aee..19d82f6 100644
--- a/src/marshmallow/fields.py
+++ b/src/marshmallow/fields.py
@@ -1487,6 +1487,8 @@ def __init__(
         self,
         precision: str = SECONDS,
         serialization_type: type[int | float] = int,
+        *,
+        deserialization_type: type[int | float] = int,
         **kwargs,
     ):
         precision = precision.lower()
@@ -1508,9 +1510,12 @@ def __init__(
 
         if serialization_type not in (int, float):
             raise ValueError("The serialization type must be one of int or float")
+        if deserialization_type not in (int, float):
+            raise ValueError("The deserialization type must be one of int or float")
 
         self.precision = precision
         self.serialization_type = serialization_type
+        self.deserialization_type = deserialization_type
         super().__init__(**kwargs)
 
     def _serialize(self, value, attr, obj, **kwargs):
@@ -1527,8 +1532,15 @@ def _serialize(self, value, attr, obj, **kwargs):
         return value.total_seconds() / base_unit.total_seconds()
 
     def _deserialize(self, value, attr, data, **kwargs):
+        if isinstance(value, float) and self.deserialization_type is not float:
+            warnings.warn(
+                f"float value for attribute {attr!r} will truncated to an integer value in {self.precision}. "
+                + "It will be deserialized as a float in marshmallow 3.25.0.",
+                UserWarning,
+                stacklevel=2,
+            )
         try:
-            value = float(value)
+            value = self.deserialization_type(value)
         except (TypeError, ValueError) as error:
             raise self.make_error("invalid") from error
```

3. Release this in 3.24.0.
4. Change the default `deserialization_type` to `float` and remove the warning.
5. Release 3.25.0

this warns potentially-affected users of the change. but maybe it's not worth the added API surface? ¯\\_(ツ)_/¯
deckar01: `total_seconds()` returns a float, so that should be the default. I don’t think it needs an extra arg (deserialize from == serialize to). Truncation was documented and tested, so I think it needs a major release, even though the use case is questionable.

We might consider deprecating the type arg while we are at it. The truncation can be enveloped or a custom field and does not seem to warrant a convenience API.
ddelange: do you suggest to deprecate `serialization_type` altogether (major release) and make float the default? sounds good to me 
lafrech: It seems to me that the current code allows everything using the right
parameters and it's just the default behaviour that is being discussed,
so there's no need to rush fixing/improving this in 3.x by adding
another parameter or a deprecation notice.

Simplifying things and removing parameters can be done in a 4.x change.
We could pick the low-hanging fruits from the 4.0 milestone and ship
soonish, postponing the other issues to 5.0.

-- 
Jérôme

sloria: @lafrech if i'm understanding your suggestion correctly:

1. change this PR to remove the `serialization_type` parameter and mark this for 4.0
2. optionally merge other small-but-breaking changes

in environs, `serialization_type` can be defaulted to `float` for marshmallow 3 compatibility.

if this is the proposal, i'm good with it
lafrech: Yes, scratch this and keep things as is in 3.x, then drop
`serialization_type` and only support the `float` case in 4.0 as
@deckar01 suggests.

And rather than postpone 4.0 endlessly, select PRs from the 4.0
milestone that can be finalized easily.

ddelange: `TimeDelta._serialize` is now 5x faster:
```python
In [1]: from marshmallow import utils
   ...: from datetime import timedelta
   ...:
   ...: WEEKS = "weeks"
   ...: DAYS = "days"
   ...: HOURS = "hours"
   ...: MINUTES = "minutes"
   ...: SECONDS = "seconds"
   ...: MILLISECONDS = "milliseconds"
   ...: MICROSECONDS = "microseconds"
   ...:
   ...: _unit_to_microseconds_mapping = {
   ...:     WEEKS: 1000000 * 60 * 60 * 24 * 7,
   ...:     DAYS: 1000000 * 60 * 60 * 24,
   ...:     HOURS: 1000000 * 60 * 60,
   ...:     MINUTES: 1000000 * 60,
   ...:     SECONDS: 1000000,
   ...:     MILLISECONDS: 1000,
   ...:     MICROSECONDS: 1,
   ...: }
   ...:
   ...: def serialize_new(value):
   ...:     precision = 'weeks'
   ...:     microseconds: int = utils.timedelta_to_microseconds(value)
   ...:     microseconds_per_unit: int = _unit_to_microseconds_mapping[precision]
   ...:     return microseconds / microseconds_per_unit
   ...:
   ...: def serialize_old(value):
   ...:     precision = 'weeks'
   ...:     base_unit = timedelta(**{precision: 1})
   ...:     return value.total_seconds() / base_unit.total_seconds()
   ...:
   ...: value = timedelta(weeks=1, microseconds=1)

In [2]: %timeit serialize_old(value)
558 ns ± 3.16 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

In [3]: %timeit serialize_new(value)
113 ns ± 0.583 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)
```

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 64ce3e11..ce9a715f 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,33 @@
 Changelog
 ---------
 
+4.0.0 (unreleased)
+******************
+
+- `marshmallow.fields.TimeDelta` no longer truncates float values when
+  deserializing (:pr:`2654`). This allows microseconds to be preserved, e.g.
+
+.. code-block:: python
+
+    from marshmallow import fields
+
+    field = fields.TimeDelta()
+
+    # Before
+    field.deserialize(12.9)
+    datetime.timedelta(seconds=12)
+    # datetime.timedelta(seconds=12)
+
+    # After
+    field.deserialize(12.9)
+    # datetime.timedelta(seconds=12, microseconds=900000)
+
+- Improve performance and minimize float precision loss of `marshmallow.fields.TimeDelta` serialization (:pr:`2654`).
+- *Backwards-incompatible*: Remove `serialization_type` parameter from
+  `marshmallow.fields.TimeDelta` (:pr:`2654`).
+
+Thanks :user:`ddelange` for the PR.
+
 3.23.2 (2024-12-18)
 *******************
 
diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py
index ae10ff15..1225555c 100644
--- a/src/marshmallow/fields.py
+++ b/src/marshmallow/fields.py
@@ -1440,45 +1440,53 @@ class Date(DateTime):
 
 
 class TimeDelta(Field):
-    """A field that (de)serializes a :class:`datetime.timedelta` object to an
-    integer or float and vice versa. The integer or float can represent the
-    number of days, seconds or microseconds.
-
-    :param precision: Influences how the integer or float is interpreted during
-        (de)serialization. Must be 'days', 'seconds', 'microseconds',
-        'milliseconds', 'minutes', 'hours' or 'weeks'.
-    :param serialization_type: Whether to (de)serialize to a `int` or `float`.
-    :param kwargs: The same keyword arguments that :class:`Field` receives.
+    """A field that (de)serializes a :class:`datetime.timedelta` object to a `float`.
+    The `float` can represent any time unit that the :class:`datetime.timedelta` constructor
+    supports.
 
-    Integer Caveats
-    ---------------
-    Any fractional parts (which depends on the precision used) will be truncated
-    when serializing using `int`.
+    :param precision: The time unit used for (de)serialization. Must be one of 'weeks',
+        'days', 'hours', 'minutes', 'seconds', 'milliseconds' or 'microseconds'.
+    :param kwargs: The same keyword arguments that :class:`Field` receives.
 
     Float Caveats
     -------------
-    Use of `float` when (de)serializing may result in data precision loss due
-    to the way machines handle floating point values.
+    Precision loss may occur when serializing a highly precise :class:`datetime.timedelta`
+    object using a big ``precision`` unit due to floating point arithmetics.
 
-    Regardless of the precision chosen, the fractional part when using `float`
-    will always be truncated to microseconds.
-    For example, `1.12345` interpreted as microseconds will result in `timedelta(microseconds=1)`.
+    When necessary, the :class:`datetime.timedelta` constructor rounds `float` inputs
+    to whole microseconds during initialization of the object. As a result, deserializing
+    a `float` might be subject to rounding, regardless of `precision`. For example,
+    ``TimeDelta().deserialize("1.1234567") == timedelta(seconds=1, microseconds=123457)``.
 
     .. versionchanged:: 2.0.0
         Always serializes to an integer value to avoid rounding errors.
         Add `precision` parameter.
     .. versionchanged:: 3.17.0
-        Allow (de)serialization to `float` through use of a new `serialization_type` parameter.
-        `int` is the default to retain previous behaviour.
+        Allow serialization to `float` through use of a new `serialization_type` parameter.
+        Defaults to `int` for backwards compatibility. Also affects deserialization.
+    .. versionchanged:: 4.0.0
+        Remove `serialization_type` parameter and always serialize to float.
+        Value is cast to a `float` upon deserialization.
     """
 
+    WEEKS = "weeks"
     DAYS = "days"
+    HOURS = "hours"
+    MINUTES = "minutes"
     SECONDS = "seconds"
-    MICROSECONDS = "microseconds"
     MILLISECONDS = "milliseconds"
-    MINUTES = "minutes"
-    HOURS = "hours"
-    WEEKS = "weeks"
+    MICROSECONDS = "microseconds"
+
+    # cache this mapping on class level for performance
+    _unit_to_microseconds_mapping = {
+        WEEKS: 1000000 * 60 * 60 * 24 * 7,
+        DAYS: 1000000 * 60 * 60 * 24,
+        HOURS: 1000000 * 60 * 60,
+        MINUTES: 1000000 * 60,
+        SECONDS: 1000000,
+        MILLISECONDS: 1000,
+        MICROSECONDS: 1,
+    }
 
     #: Default error messages.
     default_error_messages = {
@@ -1489,49 +1497,30 @@ class TimeDelta(Field):
     def __init__(
         self,
         precision: str = SECONDS,
-        serialization_type: type[int | float] = int,
         **kwargs,
-    ):
+    ) -> None:
         precision = precision.lower()
-        units = (
-            self.DAYS,
-            self.SECONDS,
-            self.MICROSECONDS,
-            self.MILLISECONDS,
-            self.MINUTES,
-            self.HOURS,
-            self.WEEKS,
-        )
 
-        if precision not in units:
-            msg = 'The precision must be {} or "{}".'.format(
-                ", ".join([f'"{each}"' for each in units[:-1]]), units[-1]
-            )
+        if precision not in self._unit_to_microseconds_mapping:
+            units = ", ".join(self._unit_to_microseconds_mapping)
+            msg = f"The precision must be one of: {units}."
             raise ValueError(msg)
 
-        if serialization_type not in (int, float):
-            raise ValueError("The serialization type must be one of int or float")
-
         self.precision = precision
-        self.serialization_type = serialization_type
         super().__init__(**kwargs)
 
-    def _serialize(self, value, attr, obj, **kwargs):
+    def _serialize(self, value, attr, obj, **kwargs) -> float | None:
         if value is None:
             return None
 
-        base_unit = dt.timedelta(**{self.precision: 1})
+        # limit float arithmetics to a single division to minimize precision loss
+        microseconds: int = utils.timedelta_to_microseconds(value)
+        microseconds_per_unit: int = self._unit_to_microseconds_mapping[self.precision]
+        return microseconds / microseconds_per_unit
 
-        if self.serialization_type is int:
-            delta = utils.timedelta_to_microseconds(value)
-            unit = utils.timedelta_to_microseconds(base_unit)
-            return delta // unit
-        assert self.serialization_type is float
-        return value.total_seconds() / base_unit.total_seconds()
-
-    def _deserialize(self, value, attr, data, **kwargs):
+    def _deserialize(self, value, attr, data, **kwargs) -> dt.timedelta:
         try:
-            value = self.serialization_type(value)
+            value = float(value)
         except (TypeError, ValueError) as error:
             raise self.make_error("invalid") from error
 
diff --git a/src/marshmallow/utils.py b/src/marshmallow/utils.py
index 7eac3453..f7247319 100644
--- a/src/marshmallow/utils.py
+++ b/src/marshmallow/utils.py
@@ -363,9 +363,9 @@ def resolve_field_instance(cls_or_instance):
 
 
 def timedelta_to_microseconds(value: dt.timedelta) -> int:
-    """Compute the total microseconds of a timedelta
+    """Compute the total microseconds of a timedelta.
 
-    https://github.com/python/cpython/blob/bb3e0c240bc60fe08d332ff5955d54197f79751c/Lib/datetime.py#L665-L667  # noqa: B950
+    https://github.com/python/cpython/blob/v3.13.1/Lib/_pydatetime.py#L805-L807
     """
     return (value.days * (24 * 3600) + value.seconds) * 1000000 + value.microseconds
 


Test patch:
diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py
index 80d44272..ff20bbf6 100644
--- a/tests/test_deserialization.py
+++ b/tests/test_deserialization.py
@@ -698,7 +698,7 @@ class TestFieldDeserialization:
         assert field.deserialize(value) == expected
 
     def test_invalid_timedelta_precision(self):
-        with pytest.raises(ValueError, match='The precision must be "days",'):
+        with pytest.raises(ValueError, match="The precision must be one of: weeks,"):
             fields.TimeDelta("invalid")
 
     def test_timedelta_field_deserialization(self):
@@ -709,6 +709,13 @@ class TestFieldDeserialization:
         assert result.seconds == 42
         assert result.microseconds == 0
 
+        field = fields.TimeDelta()
+        result = field.deserialize("42.9")
+        assert isinstance(result, dt.timedelta)
+        assert result.days == 0
+        assert result.seconds == 42
+        assert result.microseconds == 900000
+
         field = fields.TimeDelta(fields.TimeDelta.SECONDS)
         result = field.deserialize(100000)
         assert result.days == 1
@@ -741,7 +748,7 @@ class TestFieldDeserialization:
         assert isinstance(result, dt.timedelta)
         assert result.days == 0
         assert result.seconds == 12
-        assert result.microseconds == 0
+        assert result.microseconds == 900000
 
         field = fields.TimeDelta(fields.TimeDelta.WEEKS)
         result = field.deserialize(1)
@@ -772,7 +779,7 @@ class TestFieldDeserialization:
         assert result.microseconds == 456000
 
         total_microseconds_value = 322.0
-        field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS, float)
+        field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS)
         result = field.deserialize(total_microseconds_value)
         assert isinstance(result, dt.timedelta)
         unit_value = dt.timedelta(microseconds=1).total_seconds()
@@ -781,7 +788,7 @@ class TestFieldDeserialization:
         )
 
         total_microseconds_value = 322.12345
-        field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS, float)
+        field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS)
         result = field.deserialize(total_microseconds_value)
         assert isinstance(result, dt.timedelta)
         unit_value = dt.timedelta(microseconds=1).total_seconds()
@@ -790,7 +797,7 @@ class TestFieldDeserialization:
         )
 
         total_milliseconds_value = 322.223
-        field = fields.TimeDelta(fields.TimeDelta.MILLISECONDS, float)
+        field = fields.TimeDelta(fields.TimeDelta.MILLISECONDS)
         result = field.deserialize(total_milliseconds_value)
         assert isinstance(result, dt.timedelta)
         unit_value = dt.timedelta(milliseconds=1).total_seconds()
@@ -799,34 +806,34 @@ class TestFieldDeserialization:
         )
 
         total_seconds_value = 322.223
-        field = fields.TimeDelta(fields.TimeDelta.SECONDS, float)
+        field = fields.TimeDelta(fields.TimeDelta.SECONDS)
         result = field.deserialize(total_seconds_value)
         assert isinstance(result, dt.timedelta)
         assert math.isclose(result.total_seconds(), total_seconds_value)
 
         total_minutes_value = 322.223
-        field = fields.TimeDelta(fields.TimeDelta.MINUTES, float)
+        field = fields.TimeDelta(fields.TimeDelta.MINUTES)
         result = field.deserialize(total_minutes_value)
         assert isinstance(result, dt.timedelta)
         unit_value = dt.timedelta(minutes=1).total_seconds()
         assert math.isclose(result.total_seconds() / unit_value, total_minutes_value)
 
         total_hours_value = 322.223
-        field = fields.TimeDelta(fields.TimeDelta.HOURS, float)
+        field = fields.TimeDelta(fields.TimeDelta.HOURS)
         result = field.deserialize(total_hours_value)
         assert isinstance(result, dt.timedelta)
         unit_value = dt.timedelta(hours=1).total_seconds()
         assert math.isclose(result.total_seconds() / unit_value, total_hours_value)
 
         total_days_value = 322.223
-        field = fields.TimeDelta(fields.TimeDelta.DAYS, float)
+        field = fields.TimeDelta(fields.TimeDelta.DAYS)
         result = field.deserialize(total_days_value)
         assert isinstance(result, dt.timedelta)
         unit_value = dt.timedelta(days=1).total_seconds()
         assert math.isclose(result.total_seconds() / unit_value, total_days_value)
 
         total_weeks_value = 322.223
-        field = fields.TimeDelta(fields.TimeDelta.WEEKS, float)
+        field = fields.TimeDelta(fields.TimeDelta.WEEKS)
         result = field.deserialize(total_weeks_value)
         assert isinstance(result, dt.timedelta)
         unit_value = dt.timedelta(weeks=1).total_seconds()
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index df778ce8..d43defb9 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -704,25 +704,25 @@ class TestFieldSerialization:
         )
 
         field = fields.TimeDelta(fields.TimeDelta.DAYS)
-        assert field.serialize("d1", user) == 1
+        assert field.serialize("d1", user) == 1.0000115740856481
         field = fields.TimeDelta(fields.TimeDelta.SECONDS)
-        assert field.serialize("d1", user) == 86401
+        assert field.serialize("d1", user) == 86401.000001
         field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS)
         assert field.serialize("d1", user) == 86401000001
         field = fields.TimeDelta(fields.TimeDelta.HOURS)
-        assert field.serialize("d1", user) == 24
+        assert field.serialize("d1", user) == 24.000277778055555
 
         field = fields.TimeDelta(fields.TimeDelta.DAYS)
-        assert field.serialize("d2", user) == 1
+        assert field.serialize("d2", user) == 1.0000115740856481
         field = fields.TimeDelta(fields.TimeDelta.SECONDS)
-        assert field.serialize("d2", user) == 86401
+        assert field.serialize("d2", user) == 86401.000001
         field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS)
         assert field.serialize("d2", user) == 86401000001
 
         field = fields.TimeDelta(fields.TimeDelta.DAYS)
-        assert field.serialize("d3", user) == 1
+        assert field.serialize("d3", user) == 1.0000115740856481
         field = fields.TimeDelta(fields.TimeDelta.SECONDS)
-        assert field.serialize("d3", user) == 86401
+        assert field.serialize("d3", user) == 86401.000001
         field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS)
         assert field.serialize("d3", user) == 86401000001
 
@@ -741,38 +741,30 @@ class TestFieldSerialization:
         assert field.serialize("d5", user) == -86400000000
 
         field = fields.TimeDelta(fields.TimeDelta.WEEKS)
-        assert field.serialize("d6", user) == 1
+        assert field.serialize("d6", user) == 1.1489103852529763
         field = fields.TimeDelta(fields.TimeDelta.DAYS)
-        assert field.serialize("d6", user) == 7 + 1
+        assert field.serialize("d6", user) == 8.042372696770833
         field = fields.TimeDelta(fields.TimeDelta.HOURS)
-        assert field.serialize("d6", user) == 7 * 24 + 24 + 1
+        assert field.serialize("d6", user) == 193.0169447225
         field = fields.TimeDelta(fields.TimeDelta.MINUTES)
-        assert field.serialize("d6", user) == 7 * 24 * 60 + 24 * 60 + 60 + 1
-        d6_seconds = (
-            7 * 24 * 60 * 60
-            + 24 * 60 * 60  # 1 week
-            + 60 * 60  # 1 day
-            + 60  # 1 hour
-            + 1  # 1 minute
-        )
+        assert field.serialize("d6", user) == 11581.01668335
         field = fields.TimeDelta(fields.TimeDelta.SECONDS)
-        assert field.serialize("d6", user) == d6_seconds
+        assert field.serialize("d6", user) == 694861.001001
         field = fields.TimeDelta(fields.TimeDelta.MILLISECONDS)
-        assert field.serialize("d6", user) == d6_seconds * 1000 + 1
+        assert field.serialize("d6", user) == 694861001.001
         field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS)
-        assert field.serialize("d6", user) == d6_seconds * 10**6 + 1000 + 1
+        assert field.serialize("d6", user) == 694861001001
 
         user.d7 = None
         assert field.serialize("d7", user) is None
 
-        # https://github.com/marshmallow-code/marshmallow/issues/1856
         user.d8 = dt.timedelta(milliseconds=345)
         field = fields.TimeDelta(fields.TimeDelta.MILLISECONDS)
         assert field.serialize("d8", user) == 345
 
         user.d9 = dt.timedelta(milliseconds=1999)
         field = fields.TimeDelta(fields.TimeDelta.SECONDS)
-        assert field.serialize("d9", user) == 1
+        assert field.serialize("d9", user) == 1.999
 
         user.d10 = dt.timedelta(
             weeks=1,
@@ -784,48 +776,45 @@ class TestFieldSerialization:
             microseconds=742,
         )
 
-        field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS, float)
+        field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS)
         unit_value = dt.timedelta(microseconds=1).total_seconds()
         assert math.isclose(
             field.serialize("d10", user), user.d10.total_seconds() / unit_value
         )
 
-        field = fields.TimeDelta(fields.TimeDelta.MILLISECONDS, float)
+        field = fields.TimeDelta(fields.TimeDelta.MILLISECONDS)
         unit_value = dt.timedelta(milliseconds=1).total_seconds()
         assert math.isclose(
             field.serialize("d10", user), user.d10.total_seconds() / unit_value
         )
 
-        field = fields.TimeDelta(fields.TimeDelta.SECONDS, float)
+        field = fields.TimeDelta(fields.TimeDelta.SECONDS)
         assert math.isclose(field.serialize("d10", user), user.d10.total_seconds())
 
-        field = fields.TimeDelta(fields.TimeDelta.MINUTES, float)
+        field = fields.TimeDelta(fields.TimeDelta.MINUTES)
         unit_value = dt.timedelta(minutes=1).total_seconds()
         assert math.isclose(
             field.serialize("d10", user), user.d10.total_seconds() / unit_value
         )
 
-        field = fields.TimeDelta(fields.TimeDelta.HOURS, float)
+        field = fields.TimeDelta(fields.TimeDelta.HOURS)
         unit_value = dt.timedelta(hours=1).total_seconds()
         assert math.isclose(
             field.serialize("d10", user), user.d10.total_seconds() / unit_value
         )
 
-        field = fields.TimeDelta(fields.TimeDelta.DAYS, float)
+        field = fields.TimeDelta(fields.TimeDelta.DAYS)
         unit_value = dt.timedelta(days=1).total_seconds()
         assert math.isclose(
             field.serialize("d10", user), user.d10.total_seconds() / unit_value
         )
 
-        field = fields.TimeDelta(fields.TimeDelta.WEEKS, float)
+        field = fields.TimeDelta(fields.TimeDelta.WEEKS)
         unit_value = dt.timedelta(weeks=1).total_seconds()
         assert math.isclose(
             field.serialize("d10", user), user.d10.total_seconds() / unit_value
         )
 
-        with pytest.raises(ValueError):
-            fields.TimeDelta(fields.TimeDelta.SECONDS, str)
-
     def test_datetime_list_field(self):
         obj = DateTimeList([dt.datetime.now(dt.timezone.utc), dt.datetime.now()])
         field = fields.List(fields.DateTime)