Repository: python-telegram-bot/python-telegram-bot
Pull request: Localize datetimes to defaults timezone
When done, will close #3595

### Checklist for PRs

- [x] Added `.. versionadded:: version`, `.. versionchanged:: version` or `.. deprecated:: version` to the docstrings for user facing changes (for methods/class descriptions, arguments and attributes)
- [x] Created new or adapted existing unit tests
- [x] Documented code changes according to the [CSI standard](https://standards.mousepawmedia.com/en/stable/csi.html)
- [x] Added myself alphabetically to `AUTHORS.rst` (optional)



Problem statement:
[FEATURE] Message.date is UTC-localized
### Steps to Reproduce

`Message.date` is a datetime object created by PTB converting the original unix timestamp sent by telegram. It IS timezone aware, but uses UTC.

### Expected behaviour

I fully expected that - because I've setup my local timezone using `Default.tzinfo` and passing it to `ApplicationBuilder.defaults` - the received `Message.date` would be localized in my local timezone.

### Actual behaviour

This is not the case. AFAIK `Default.tzinfo` is only used to localize the datetime objects used for job_queue's jobs.

### Operating System

Raspberry OS

### Version of Python, python-telegram-bot & dependencies

```shell
python-telegram-bot 20.1
Bot API 6.5
Python 3.11.0 (main, Nov 23 2022, 10:42:54) [GCC 10.2.1 20210110]
```


### Relevant log output

_No response_

### Additional Context

_No response_

Hints from issue discussion:
I originally went with saying they should be UTC as telegram is, since that is what servers should be set to imo.

I now think differently: If I set this in Defaults, I would expect all datetime objects being build by PTB to be in that timezone.

I would also be open to have this behavior set with a second Default argument.
>  If I set this in Defaults, I would expect all datetime objects being build by PTB to be in that timezone.

+1 from me.

> I would also be open to have this behavior set with a second Default argument.

-1 from me :D
With an additional offline +1 from @harshil21 and +0 from @lemontree210 , I declare this proposal accepted 🙆 

An implementation should adjust the `de_json` of all classes that have datetime attributes to localize the datetimes to the appropriate timezone. Extracting the corresponding timezone from the `bot` argument can look similar to

https://github.com/python-telegram-bot/python-telegram-bot/blob/33b677aeb4a3e601668103914a70e633908e90b2/telegram/_message.py#L994-L999

The logic of extracting the timezone and localizing the datime object should be implemented in a helper function that can be re-used in all the different classes. This helper function can go into https://github.com/python-telegram-bot/python-telegram-bot/blob/master/telegram/_utils/datetime.py and can even make use of 

https://github.com/python-telegram-bot/python-telegram-bot/blob/33b677aeb4a3e601668103914a70e633908e90b2/telegram/_utils/datetime.py#L45-L49

Additional unit tests should be added to ensure that the localization is correctly done if

* no default is set
* a custom tzinfo is set as default
* the class `Bot` is used instead of `ExtBot`

For this the fixtures

https://github.com/python-telegram-bot/python-telegram-bot/blob/33b677aeb4a3e601668103914a70e633908e90b2/tests/conftest.py#L165-L174

and 

https://github.com/python-telegram-bot/python-telegram-bot/blob/33b677aeb4a3e601668103914a70e633908e90b2/tests/conftest.py#L133-L142

will be useful.

Moreover, the docstrings of the affected attributes should include `.. versionchanged` directives that explain the new localization. A sphinx-insertion may be used for this.

@Trifase would you be interested in drafting a PR yourself?
Sure! This will be my first contribution to a proper project, so expect that it will take some time and some help, tho.
@Trifase Good things always take time.

Commit message: This should be the last test

Patch to review:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index c3aa909cc1a..b3a87a6c348 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -72,6 +72,7 @@ The following wonderful people contributed directly or indirectly to this projec
 - `Li-aung Yip <https://github.com/LiaungYip>`_
 - `Loo Zheng Yuan <https://github.com/loozhengyuan>`_
 - `LRezende <https://github.com/lrezende>`_
+- `Luca Bellanti <https://github.com/Trifase>`_
 - `macrojames <https://github.com/macrojames>`_
 - `Matheus Lemos <https://github.com/mlemosf>`_
 - `Michael Dix <https://github.com/Eisberge>`_
diff --git a/docs/substitutions/global.rst b/docs/substitutions/global.rst
index d62c2f96993..4d216083360 100644
--- a/docs/substitutions/global.rst
+++ b/docs/substitutions/global.rst
@@ -55,3 +55,5 @@
 .. |sequenceargs| replace:: Accepts any :class:`collections.abc.Sequence` as input instead of just a list.
 
 .. |captionentitiesattr| replace:: Tuple of special entities that appear in the caption, which can be specified instead of ``parse_mode``.
+
+.. |datetime_localization| replace:: The default timezone of the bot is used for localization, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is used.
diff --git a/telegram/_chatinvitelink.py b/telegram/_chatinvitelink.py
index a4611a2c3cb..058b0a5ff0f 100644
--- a/telegram/_chatinvitelink.py
+++ b/telegram/_chatinvitelink.py
@@ -22,7 +22,7 @@
 
 from telegram._telegramobject import TelegramObject
 from telegram._user import User
-from telegram._utils.datetime import from_timestamp
+from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp
 from telegram._utils.types import JSONDict
 
 if TYPE_CHECKING:
@@ -54,6 +54,9 @@ class ChatInviteLink(TelegramObject):
         is_revoked (:obj:`bool`): :obj:`True`, if the link is revoked.
         expire_date (:class:`datetime.datetime`, optional): Date when the link will expire or
             has been expired.
+
+            .. versionchanged:: NEXT.VERSION
+                |datetime_localization|
         member_limit (:obj:`int`, optional): Maximum number of users that can be members of the
             chat simultaneously after joining the chat via this invite link;
             :tg-const:`telegram.constants.ChatInviteLinkLimit.MIN_MEMBER_LIMIT`-
@@ -78,6 +81,9 @@ class ChatInviteLink(TelegramObject):
         is_revoked (:obj:`bool`): :obj:`True`, if the link is revoked.
         expire_date (:class:`datetime.datetime`): Optional. Date when the link will expire or
             has been expired.
+
+            .. versionchanged:: NEXT.VERSION
+                |datetime_localization|
         member_limit (:obj:`int`): Optional. Maximum number of users that can be members
             of the chat simultaneously after joining the chat via this invite link;
             :tg-const:`telegram.constants.ChatInviteLinkLimit.MIN_MEMBER_LIMIT`-
@@ -152,7 +158,10 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatInviteLi
         if not data:
             return None
 
+        # Get the local timezone from the bot if it has defaults
+        loc_tzinfo = extract_tzinfo_from_defaults(bot)
+
         data["creator"] = User.de_json(data.get("creator"), bot)
-        data["expire_date"] = from_timestamp(data.get("expire_date", None))
+        data["expire_date"] = from_timestamp(data.get("expire_date", None), tzinfo=loc_tzinfo)
 
         return super().de_json(data=data, bot=bot)
diff --git a/telegram/_chatjoinrequest.py b/telegram/_chatjoinrequest.py
index 610d9ff54e8..4d349ea70d5 100644
--- a/telegram/_chatjoinrequest.py
+++ b/telegram/_chatjoinrequest.py
@@ -24,7 +24,7 @@
 from telegram._chatinvitelink import ChatInviteLink
 from telegram._telegramobject import TelegramObject
 from telegram._user import User
-from telegram._utils.datetime import from_timestamp
+from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp
 from telegram._utils.defaultvalue import DEFAULT_NONE
 from telegram._utils.types import JSONDict, ODVInput
 
@@ -56,6 +56,9 @@ class ChatJoinRequest(TelegramObject):
         chat (:class:`telegram.Chat`): Chat to which the request was sent.
         from_user (:class:`telegram.User`): User that sent the join request.
         date (:class:`datetime.datetime`): Date the request was sent.
+
+            .. versionchanged:: NEXT.VERSION
+                |datetime_localization|
         user_chat_id (:obj:`int`): Identifier of a private chat with the user who sent the join
             request. This number may have more than 32 significant bits and some programming
             languages may have difficulty/silent defects in interpreting it. But it has at most 52
@@ -73,6 +76,9 @@ class ChatJoinRequest(TelegramObject):
         chat (:class:`telegram.Chat`): Chat to which the request was sent.
         from_user (:class:`telegram.User`): User that sent the join request.
         date (:class:`datetime.datetime`): Date the request was sent.
+
+            .. versionchanged:: NEXT.VERSION
+                |datetime_localization|
         user_chat_id (:obj:`int`): Identifier of a private chat with the user who sent the join
             request. This number may have more than 32 significant bits and some programming
             languages may have difficulty/silent defects in interpreting it. But it has at most 52
@@ -124,9 +130,12 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatJoinRequ
         if not data:
             return None
 
+        # Get the local timezone from the bot if it has defaults
+        loc_tzinfo = extract_tzinfo_from_defaults(bot)
+
         data["chat"] = Chat.de_json(data.get("chat"), bot)
         data["from_user"] = User.de_json(data.pop("from", None), bot)
-        data["date"] = from_timestamp(data.get("date", None))
+        data["date"] = from_timestamp(data.get("date", None), tzinfo=loc_tzinfo)
         data["invite_link"] = ChatInviteLink.de_json(data.get("invite_link"), bot)
 
         return super().de_json(data=data, bot=bot)
diff --git a/telegram/_chatmember.py b/telegram/_chatmember.py
index bdcdd8663c3..b73cc789616 100644
--- a/telegram/_chatmember.py
+++ b/telegram/_chatmember.py
@@ -23,7 +23,7 @@
 from telegram import constants
 from telegram._telegramobject import TelegramObject
 from telegram._user import User
-from telegram._utils.datetime import from_timestamp
+from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp
 from telegram._utils.types import JSONDict
 
 if TYPE_CHECKING:
@@ -125,7 +125,10 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatMember"]
 
         data["user"] = User.de_json(data.get("user"), bot)
         if "until_date" in data:
-            data["until_date"] = from_timestamp(data["until_date"])
+            # Get the local timezone from the bot if it has defaults
+            loc_tzinfo = extract_tzinfo_from_defaults(bot)
+
+            data["until_date"] = from_timestamp(data["until_date"], tzinfo=loc_tzinfo)
 
         return super().de_json(data=data, bot=bot)
 
@@ -386,6 +389,9 @@ class ChatMemberRestricted(ChatMember):
             .. versionadded:: 20.0
         until_date (:class:`datetime.datetime`): Date when restrictions
            will be lifted for this user.
+
+            .. versionchanged:: NEXT.VERSION
+                |datetime_localization|
         can_send_audios (:obj:`bool`): :obj:`True`, if the user is allowed to send audios.
 
             .. versionadded:: 20.1
@@ -438,6 +444,9 @@ class ChatMemberRestricted(ChatMember):
             .. versionadded:: 20.0
         until_date (:class:`datetime.datetime`): Date when restrictions
            will be lifted for this user.
+
+            .. versionchanged:: NEXT.VERSION
+                |datetime_localization|
         can_send_audios (:obj:`bool`): :obj:`True`, if the user is allowed to send audios.
 
             .. versionadded:: 20.1
@@ -565,6 +574,9 @@ class ChatMemberBanned(ChatMember):
         until_date (:class:`datetime.datetime`): Date when restrictions
            will be lifted for this user.
 
+            .. versionchanged:: NEXT.VERSION
+                |datetime_localization|
+
     Attributes:
         status (:obj:`str`): The member's status in the chat,
             always :tg-const:`telegram.ChatMember.BANNED`.
@@ -572,6 +584,9 @@ class ChatMemberBanned(ChatMember):
         until_date (:class:`datetime.datetime`): Date when restrictions
            will be lifted for this user.
 
+            .. versionchanged:: NEXT.VERSION
+                |datetime_localization|
+
     """
 
     __slots__ = ("until_date",)
diff --git a/telegram/_chatmemberupdated.py b/telegram/_chatmemberupdated.py
index 78f6c18cbaa..9a09b13d10d 100644
--- a/telegram/_chatmemberupdated.py
+++ b/telegram/_chatmemberupdated.py
@@ -25,7 +25,7 @@
 from telegram._chatmember import ChatMember
 from telegram._telegramobject import TelegramObject
 from telegram._user import User
-from telegram._utils.datetime import from_timestamp
+from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp
 from telegram._utils.types import JSONDict
 
 if TYPE_CHECKING:
@@ -118,9 +118,12 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatMemberUp
         if not data:
             return None
 
+        # Get the local timezone from the bot if it has defaults
+        loc_tzinfo = extract_tzinfo_from_defaults(bot)
+
         data["chat"] = Chat.de_json(data.get("chat"), bot)
         data["from_user"] = User.de_json(data.pop("from", None), bot)
-        data["date"] = from_timestamp(data.get("date"))
+        data["date"] = from_timestamp(data.get("date"), tzinfo=loc_tzinfo)
         data["old_chat_member"] = ChatMember.de_json(data.get("old_chat_member"), bot)
         data["new_chat_member"] = ChatMember.de_json(data.get("new_chat_member"), bot)
         data["invite_link"] = ChatInviteLink.de_json(data.get("invite_link"), bot)
diff --git a/telegram/_message.py b/telegram/_message.py
index 31425731ea9..fd8daf47832 100644
--- a/telegram/_message.py
+++ b/telegram/_message.py
@@ -56,7 +56,7 @@
 from telegram._telegramobject import TelegramObject
 from telegram._user import User
 from telegram._utils.argumentparsing import parse_sequence_arg
-from telegram._utils.datetime import from_timestamp
+from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp
 from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue
 from telegram._utils.types import DVInput, FileInput, JSONDict, ODVInput, ReplyMarkup
 from telegram._videochat import (
@@ -121,6 +121,9 @@ class Message(TelegramObject):
             sent on behalf of a chat.
         date (:class:`datetime.datetime`): Date the message was sent in Unix time. Converted to
             :class:`datetime.datetime`.
+
+            .. versionchanged:: NEXT.VERSION
+                |datetime_localization|
         chat (:class:`telegram.Chat`): Conversation the message belongs to.
         forward_from (:class:`telegram.User`, optional): For forwarded messages, sender of
             the original message.
@@ -338,6 +341,9 @@ class Message(TelegramObject):
             sent on behalf of a chat.
         date (:class:`datetime.datetime`): Date the message was sent in Unix time. Converted to
             :class:`datetime.datetime`.
+
+            .. versionchanged:: NEXT.VERSION
+                |datetime_localization|
         chat (:class:`telegram.Chat`): Conversation the message belongs to.
         forward_from (:class:`telegram.User`): Optional. For forwarded messages, sender of the
             original message.
@@ -841,17 +847,20 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Message"]:
         if not data:
             return None
 
+        # Get the local timezone from the bot if it has defaults
+        loc_tzinfo = extract_tzinfo_from_defaults(bot)
+
         data["from_user"] = User.de_json(data.pop("from", None), bot)
         data["sender_chat"] = Chat.de_json(data.get("sender_chat"), bot)
-        data["date"] = from_timestamp(data["date"])
+        data["date"] = from_timestamp(data["date"], tzinfo=loc_tzinfo)
         data["chat"] = Chat.de_json(data.get("chat"), bot)
         data["entities"] = MessageEntity.de_list(data.get("entities"), bot)
         data["caption_entities"] = MessageEntity.de_list(data.get("caption_entities"), bot)
         data["forward_from"] = User.de_json(data.get("forward_from"), bot)
         data["forward_from_chat"] = Chat.de_json(data.get("forward_from_chat"), bot)
-        data["forward_date"] = from_timestamp(data.get("forward_date"))
+        data["forward_date"] = from_timestamp(data.get("forward_date"), tzinfo=loc_tzinfo)
         data["reply_to_message"] = Message.de_json(data.get("reply_to_message"), bot)
-        data["edit_date"] = from_timestamp(data.get("edit_date"))
+        data["edit_date"] = from_timestamp(data.get("edit_date"), tzinfo=loc_tzinfo)
         data["audio"] = Audio.de_json(data.get("audio"), bot)
         data["document"] = Document.de_json(data.get("document"), bot)
         data["animation"] = Animation.de_json(data.get("animation"), bot)
diff --git a/telegram/_poll.py b/telegram/_poll.py
index 2182d3146bc..0e1d56c62ad 100644
--- a/telegram/_poll.py
+++ b/telegram/_poll.py
@@ -26,7 +26,7 @@
 from telegram._user import User
 from telegram._utils import enum
 from telegram._utils.argumentparsing import parse_sequence_arg
-from telegram._utils.datetime import from_timestamp
+from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp
 from telegram._utils.types import JSONDict
 
 if TYPE_CHECKING:
@@ -173,6 +173,9 @@ class Poll(TelegramObject):
         close_date (:obj:`datetime.datetime`, optional): Point in time (Unix timestamp) when the
             poll will be automatically closed. Converted to :obj:`datetime.datetime`.
 
+            .. versionchanged:: NEXT.VERSION
+                |datetime_localization|
+
     Attributes:
         id (:obj:`str`): Unique poll identifier.
         question (:obj:`str`): Poll question, :tg-const:`telegram.Poll.MIN_QUESTION_LENGTH`-
@@ -206,6 +209,9 @@ class Poll(TelegramObject):
         close_date (:obj:`datetime.datetime`): Optional. Point in time when the poll will be
             automatically closed.
 
+            .. versionchanged:: NEXT.VERSION
+                |datetime_localization|
+
     """
 
     __slots__ = (
@@ -271,9 +277,12 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Poll"]:
         if not data:
             return None
 
+        # Get the local timezone from the bot if it has defaults
+        loc_tzinfo = extract_tzinfo_from_defaults(bot)
+
         data["options"] = [PollOption.de_json(option, bot) for option in data["options"]]
         data["explanation_entities"] = MessageEntity.de_list(data.get("explanation_entities"), bot)
-        data["close_date"] = from_timestamp(data.get("close_date"))
+        data["close_date"] = from_timestamp(data.get("close_date"), tzinfo=loc_tzinfo)
 
         return super().de_json(data=data, bot=bot)
 
diff --git a/telegram/_utils/datetime.py b/telegram/_utils/datetime.py
index 7b647214e6e..c513f73c8c3 100644
--- a/telegram/_utils/datetime.py
+++ b/telegram/_utils/datetime.py
@@ -29,7 +29,10 @@
 """
 import datetime as dtm  # skipcq: PYL-W0406
 import time
-from typing import Optional, Union
+from typing import TYPE_CHECKING, Optional, Union
+
+if TYPE_CHECKING:
+    from telegram import Bot
 
 # pytz is only available if it was installed as dependency of APScheduler, so we make a little
 # workaround here
@@ -162,7 +165,10 @@ def to_timestamp(
     )
 
 
-def from_timestamp(unixtime: Optional[int], tzinfo: dtm.tzinfo = UTC) -> Optional[dtm.datetime]:
+def from_timestamp(
+    unixtime: Optional[int],
+    tzinfo: Optional[dtm.tzinfo] = None,
+) -> Optional[dtm.datetime]:
     """
     Converts an (integer) unix timestamp to a timezone aware datetime object.
     :obj:`None` s are left alone (i.e. ``from_timestamp(None)`` is :obj:`None`).
@@ -170,7 +176,8 @@ def from_timestamp(unixtime: Optional[int], tzinfo: dtm.tzinfo = UTC) -> Optiona
     Args:
         unixtime (:obj:`int`): Integer POSIX timestamp.
         tzinfo (:obj:`datetime.tzinfo`, optional): The timezone to which the timestamp is to be
-            converted to. Defaults to UTC.
+            converted to. Defaults to :obj:`None`, in which case the returned datetime object will
+            be timezone aware and in UTC.
 
     Returns:
         Timezone aware equivalent :obj:`datetime.datetime` value if :paramref:`unixtime` is not
@@ -179,9 +186,19 @@ def from_timestamp(unixtime: Optional[int], tzinfo: dtm.tzinfo = UTC) -> Optiona
     if unixtime is None:
         return None
 
-    if tzinfo is not None:
-        return dtm.datetime.fromtimestamp(unixtime, tz=tzinfo)
-    return dtm.datetime.utcfromtimestamp(unixtime)
+    return dtm.datetime.fromtimestamp(unixtime, tz=UTC if tzinfo is None else tzinfo)
+
+
+def extract_tzinfo_from_defaults(bot: "Bot") -> Union[dtm.tzinfo, None]:
+    """
+    Extracts the timezone info from the default values of the bot.
+    If the bot has no default values, :obj:`None` is returned.
+    """
+    # We don't use `ininstance(bot, ExtBot)` here so that this works
+    # in `python-telegram-bot-raw` as well
+    if hasattr(bot, "defaults") and bot.defaults:
+        return bot.defaults.tzinfo
+    return None
 
 
 def _datetime_to_float_timestamp(dt_obj: dtm.datetime) -> float:
diff --git a/telegram/_videochat.py b/telegram/_videochat.py
index a025809e5e6..d7b348b0ec0 100644
--- a/telegram/_videochat.py
+++ b/telegram/_videochat.py
@@ -23,7 +23,7 @@
 from telegram._telegramobject import TelegramObject
 from telegram._user import User
 from telegram._utils.argumentparsing import parse_sequence_arg
-from telegram._utils.datetime import from_timestamp
+from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp
 from telegram._utils.types import JSONDict
 
 if TYPE_CHECKING:
@@ -149,10 +149,16 @@ class VideoChatScheduled(TelegramObject):
     Args:
         start_date (:obj:`datetime.datetime`): Point in time (Unix timestamp) when the video
             chat is supposed to be started by a chat administrator
+
+            .. versionchanged:: NEXT.VERSION
+                |datetime_localization|
     Attributes:
         start_date (:obj:`datetime.datetime`): Point in time (Unix timestamp) when the video
             chat is supposed to be started by a chat administrator
 
+            .. versionchanged:: NEXT.VERSION
+                |datetime_localization|
+
     """
 
     __slots__ = ("start_date",)
@@ -178,6 +184,9 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["VideoChatSch
         if not data:
             return None
 
-        data["start_date"] = from_timestamp(data["start_date"])
+        # Get the local timezone from the bot if it has defaults
+        loc_tzinfo = extract_tzinfo_from_defaults(bot)
+
+        data["start_date"] = from_timestamp(data["start_date"], tzinfo=loc_tzinfo)
 
         return super().de_json(data=data, bot=bot)
diff --git a/telegram/_webhookinfo.py b/telegram/_webhookinfo.py
index 2b444c410e3..b5c220d679f 100644
--- a/telegram/_webhookinfo.py
+++ b/telegram/_webhookinfo.py
@@ -21,7 +21,7 @@
 
 from telegram._telegramobject import TelegramObject
 from telegram._utils.argumentparsing import parse_sequence_arg
-from telegram._utils.datetime import from_timestamp
+from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp
 from telegram._utils.types import JSONDict
 
 if TYPE_CHECKING:
@@ -154,9 +154,12 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["WebhookInfo"
         if not data:
             return None
 
-        data["last_error_date"] = from_timestamp(data.get("last_error_date"))
+        # Get the local timezone from the bot if it has defaults
+        loc_tzinfo = extract_tzinfo_from_defaults(bot)
+
+        data["last_error_date"] = from_timestamp(data.get("last_error_date"), tzinfo=loc_tzinfo)
         data["last_synchronization_error_date"] = from_timestamp(
-            data.get("last_synchronization_error_date")
+            data.get("last_synchronization_error_date"), tzinfo=loc_tzinfo
         )
 
         return super().de_json(data=data, bot=bot)
diff --git a/tests/_utils/test_datetime.py b/tests/_utils/test_datetime.py
index 3776c3da996..ca89390bcf3 100644
--- a/tests/_utils/test_datetime.py
+++ b/tests/_utils/test_datetime.py
@@ -162,7 +162,7 @@ def test_from_timestamp_none(self):
         assert tg_dtm.from_timestamp(None) is None
 
     def test_from_timestamp_naive(self):
-        datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, tzinfo=None)
+        datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, tzinfo=dtm.timezone.utc)
         assert tg_dtm.from_timestamp(1573431976, tzinfo=None) == datetime
 
     def test_from_timestamp_aware(self, timezone):
@@ -174,3 +174,8 @@ def test_from_timestamp_aware(self, timezone):
             tg_dtm.from_timestamp(1573431976.1 - timezone.utcoffset(test_datetime).total_seconds())
             == datetime
         )
+
+    def test_extract_tzinfo_from_defaults(self, tz_bot, bot, raw_bot):
+        assert tg_dtm.extract_tzinfo_from_defaults(tz_bot) == tz_bot.defaults.tzinfo
+        assert tg_dtm.extract_tzinfo_from_defaults(bot) is None
+        assert tg_dtm.extract_tzinfo_from_defaults(raw_bot) is None
diff --git a/tests/test_chatinvitelink.py b/tests/test_chatinvitelink.py
index 64110e65186..41b1ca60c24 100644
--- a/tests/test_chatinvitelink.py
+++ b/tests/test_chatinvitelink.py
@@ -21,7 +21,7 @@
 import pytest
 
 from telegram import ChatInviteLink, User
-from telegram._utils.datetime import to_timestamp
+from telegram._utils.datetime import UTC, to_timestamp
 from tests.auxil.slots import mro_slots
 
 
@@ -107,6 +107,33 @@ def test_de_json_all_args(self, bot, creator):
         assert invite_link.name == self.name
         assert invite_link.pending_join_request_count == self.pending_join_request_count
 
+    def test_de_json_localization(self, tz_bot, bot, raw_bot, creator):
+        json_dict = {
+            "invite_link": self.link,
+            "creator": creator.to_dict(),
+            "creates_join_request": self.creates_join_request,
+            "is_primary": self.primary,
+            "is_revoked": self.revoked,
+            "expire_date": to_timestamp(self.expire_date),
+            "member_limit": self.member_limit,
+            "name": self.name,
+            "pending_join_request_count": str(self.pending_join_request_count),
+        }
+
+        invite_link_raw = ChatInviteLink.de_json(json_dict, raw_bot)
+        invite_link_bot = ChatInviteLink.de_json(json_dict, bot)
+        invite_link_tz = ChatInviteLink.de_json(json_dict, tz_bot)
+
+        # comparing utcoffsets because comparing timezones is unpredicatable
+        invite_offset = datetime.datetime.utcoffset(invite_link_tz.expire_date)
+        tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(
+            invite_link_tz.expire_date.replace(tzinfo=None)
+        )
+
+        assert invite_link_raw.expire_date.tzinfo == UTC
+        assert invite_link_bot.expire_date.tzinfo == UTC
+        assert invite_offset == tz_bot_offset
+
     def test_to_dict(self, invite_link):
         invite_link_dict = invite_link.to_dict()
         assert isinstance(invite_link_dict, dict)
diff --git a/tests/test_chatjoinrequest.py b/tests/test_chatjoinrequest.py
index b62ea5ec5a9..c6fb7b6afcb 100644
--- a/tests/test_chatjoinrequest.py
+++ b/tests/test_chatjoinrequest.py
@@ -98,6 +98,26 @@ def test_de_json(self, bot, time):
         assert chat_join_request.bio == self.bio
         assert chat_join_request.invite_link == self.invite_link
 
+    def test_de_json_localization(self, tz_bot, bot, raw_bot, time):
+        json_dict = {
+            "chat": self.chat.to_dict(),
+            "from": self.from_user.to_dict(),
+            "date": to_timestamp(time),
+            "user_chat_id": self.from_user.id,
+        }
+
+        chatjoin_req_raw = ChatJoinRequest.de_json(json_dict, raw_bot)
+        chatjoin_req_bot = ChatJoinRequest.de_json(json_dict, bot)
+        chatjoin_req_tz = ChatJoinRequest.de_json(json_dict, tz_bot)
+
+        # comparing utcoffsets because comparing timezones is unpredicatable
+        chatjoin_req_offset = datetime.datetime.utcoffset(chatjoin_req_tz.date)
+        tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(chatjoin_req_tz.date.replace(tzinfo=None))
+
+        assert chatjoin_req_raw.date.tzinfo == UTC
+        assert chatjoin_req_bot.date.tzinfo == UTC
+        assert chatjoin_req_offset == tz_bot_offset
+
     def test_to_dict(self, chat_join_request, time):
         chat_join_request_dict = chat_join_request.to_dict()
 
diff --git a/tests/test_chatmember.py b/tests/test_chatmember.py
index 0455a8e23ae..46d7977fe74 100644
--- a/tests/test_chatmember.py
+++ b/tests/test_chatmember.py
@@ -33,7 +33,7 @@
     Dice,
     User,
 )
-from telegram._utils.datetime import to_timestamp
+from telegram._utils.datetime import UTC, to_timestamp
 from tests.auxil.slots import mro_slots
 
 ignored = ["self", "api_kwargs"]
@@ -218,6 +218,24 @@ def test_de_json_all_args(self, bot, chat_member_type):
         for c_mem_type_at, const_c_mem_at in iter_args(chat_member_type, const_chat_member, True):
             assert c_mem_type_at == const_c_mem_at
 
+    def test_de_json_chatmemberbanned_localization(self, chat_member_type, tz_bot, bot, raw_bot):
+        # We only test two classes because the other three don't have datetimes in them.
+        if isinstance(chat_member_type, (ChatMemberBanned, ChatMemberRestricted)):
+            json_dict = make_json_dict(chat_member_type, include_optional_args=True)
+            chatmember_raw = ChatMember.de_json(json_dict, raw_bot)
+            chatmember_bot = ChatMember.de_json(json_dict, bot)
+            chatmember_tz = ChatMember.de_json(json_dict, tz_bot)
+
+            # comparing utcoffsets because comparing timezones is unpredicatable
+            chatmember_offset = datetime.datetime.utcoffset(chatmember_tz.until_date)
+            tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(
+                chatmember_tz.until_date.replace(tzinfo=None)
+            )
+
+            assert chatmember_raw.until_date.tzinfo == UTC
+            assert chatmember_bot.until_date.tzinfo == UTC
+            assert chatmember_offset == tz_bot_offset
+
     def test_de_json_invalid_status(self, chat_member_type, bot):
         json_dict = {"status": "invalid", "user": CMDefaults.user.to_dict()}
         chat_member_type = ChatMember.de_json(json_dict, bot)
diff --git a/tests/test_message.py b/tests/test_message.py
index 741aeb29179..26165d709b5 100644
--- a/tests/test_message.py
+++ b/tests/test_message.py
@@ -56,6 +56,7 @@
     Voice,
     WebAppData,
 )
+from telegram._utils.datetime import UTC
 from telegram.constants import ChatAction, ParseMode
 from telegram.ext import Defaults
 from tests._passport.test_passport import RAW_PASSPORT_DATA
@@ -365,6 +366,19 @@ def test_all_possibilities_de_json_and_to_dict(self, bot, message_params):
         for slot in new.__slots__:
             assert not isinstance(new[slot], dict)
 
+    def test_de_json_localization(self, bot, raw_bot, tz_bot, message):
+        message_raw = Message.de_json(message.to_dict(), raw_bot)
+        message_bot = Message.de_json(message.to_dict(), bot)
+        message_tz = Message.de_json(message.to_dict(), tz_bot)
+
+        # comparing utcoffsets because comparing timezones is unpredicatable
+        message_offset = datetime.utcoffset(message_tz.date)
+        tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(message_tz.date.replace(tzinfo=None))
+
+        assert message_raw.date.tzinfo == UTC
+        assert message_bot.date.tzinfo == UTC
+        assert message_offset == tz_bot_offset
+
     def test_equality(self):
         id_ = 1
         a = Message(id_, self.date, self.chat, from_user=self.from_user)
diff --git a/tests/test_poll.py b/tests/test_poll.py
index 0250df8c7b3..1762b3f61a8 100644
--- a/tests/test_poll.py
+++ b/tests/test_poll.py
@@ -20,7 +20,7 @@
 import pytest
 
 from telegram import MessageEntity, Poll, PollAnswer, PollOption, User
-from telegram._utils.datetime import to_timestamp
+from telegram._utils.datetime import UTC, to_timestamp
 from telegram.constants import PollType
 from tests.auxil.slots import mro_slots
 
@@ -208,6 +208,36 @@ def test_de_json(self, bot):
         assert abs(poll.close_date - self.close_date) < timedelta(seconds=1)
         assert to_timestamp(poll.close_date) == to_timestamp(self.close_date)
 
+    def test_de_json_localization(self, tz_bot, bot, raw_bot):
+        json_dict = {
+            "id": self.id_,
+            "question": self.question,
+            "options": [o.to_dict() for o in self.options],
+            "total_voter_count": self.total_voter_count,
+            "is_closed": self.is_closed,
+            "is_anonymous": self.is_anonymous,
+            "type": self.type,
+            "allows_multiple_answers": self.allows_multiple_answers,
+            "explanation": self.explanation,
+            "explanation_entities": [self.explanation_entities[0].to_dict()],
+            "open_period": self.open_period,
+            "close_date": to_timestamp(self.close_date),
+        }
+
+        poll_raw = Poll.de_json(json_dict, raw_bot)
+        poll_bot = Poll.de_json(json_dict, bot)
+        poll_bot_tz = Poll.de_json(json_dict, tz_bot)
+
+        # comparing utcoffsets because comparing timezones is unpredicatable
+        poll_bot_tz_offset = datetime.utcoffset(poll_bot_tz.close_date)
+        tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(
+            poll_bot_tz.close_date.replace(tzinfo=None)
+        )
+
+        assert poll_raw.close_date.tzinfo == UTC
+        assert poll_bot.close_date.tzinfo == UTC
+        assert poll_bot_tz_offset == tz_bot_offset
+
     def test_to_dict(self, poll):
         poll_dict = poll.to_dict()
 
diff --git a/tests/test_videochat.py b/tests/test_videochat.py
index 046159bf441..482847f8f37 100644
--- a/tests/test_videochat.py
+++ b/tests/test_videochat.py
@@ -27,7 +27,7 @@
     VideoChatScheduled,
     VideoChatStarted,
 )
-from telegram._utils.datetime import to_timestamp
+from telegram._utils.datetime import UTC, to_timestamp
 from tests.auxil.slots import mro_slots
 
 
@@ -170,6 +170,23 @@ def test_de_json(self, bot):
 
         assert abs(video_chat_scheduled.start_date - self.start_date) < dtm.timedelta(seconds=1)
 
+    def test_de_json_localization(self, tz_bot, bot, raw_bot):
+        json_dict = {"start_date": to_timestamp(self.start_date)}
+
+        videochat_raw = VideoChatScheduled.de_json(json_dict, raw_bot)
+        videochat_bot = VideoChatScheduled.de_json(json_dict, bot)
+        videochat_tz = VideoChatScheduled.de_json(json_dict, tz_bot)
+
+        # comparing utcoffsets because comparing timezones is unpredicatable
+        videochat_offset = dtm.datetime.utcoffset(videochat_tz.start_date)
+        tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(
+            videochat_tz.start_date.replace(tzinfo=None)
+        )
+
+        assert videochat_raw.start_date.tzinfo == UTC
+        assert videochat_bot.start_date.tzinfo == UTC
+        assert videochat_offset == tz_bot_offset
+
     def test_to_dict(self):
         video_chat_scheduled = VideoChatScheduled(self.start_date)
         video_chat_scheduled_dict = video_chat_scheduled.to_dict()


Code review comments:
diff_hunk: @@ -107,6 +107,33 @@ def test_de_json_all_args(self, bot, creator):
         assert invite_link.name == self.name
         assert invite_link.pending_join_request_count == self.pending_join_request_count
 
+    def test_de_json_localization(self, tz_bot, bot, raw_bot, creator):
+        json_dict = {
+            "invite_link": self.link,
+            "creator": creator.to_dict(),
+            "creates_join_request": self.creates_join_request,
+            "is_primary": self.primary,
+            "is_revoked": self.revoked,
+            "expire_date": to_timestamp(self.expire_date),
+            "member_limit": self.member_limit,
+            "name": self.name,
+            "pending_join_request_count": str(self.pending_join_request_count),
+        }
+
+        invite_link_raw = ChatInviteLink.de_json(json_dict, raw_bot)
+        invite_link_bot = ChatInviteLink.de_json(json_dict, bot)
+        invite_link_tz = ChatInviteLink.de_json(json_dict, tz_bot)
+
+        # comparing utcoffsets because comparing timezones is unpredicatable
+        invite_offset = datetime.datetime.utcoffset(invite_link_tz.expire_date)
path: tests/test_chatinvitelink.py
text: @user1:
```suggestion
        invite_offset = invite_link_tz.expire_date.utcoffset()
```

`datetime.datetime.utfcoffset` is a bound method rather than a class method, so the argument that you pass to it is actually the `self`. That works, but feels strange to me … The SO link that I pointed you to also does that "wrongly".