Skip to content

Switching to Dataclasses #5279

Open

Description

@harshil21

Python dataclasses has been something this library has always eyed keenly. A lot of the work and discussion was done earlier in #2698, and a number of things have changed since that issue was created, which has once again opened the door for us to now actually switch to dataclasses.

Reiterating the main benefits we get from the switch:

  1. No more slots and __init__ boilerplate
  2. Letting dataclass machinery handle equality comparison for us
  3. Drastically less maintenance overhead, even when writing tests, since we don't need slots and equality tests.

Implementation

I'll go over things which were discussed in the issue and whether they are still applicable:

Minor details

  1. Immutability: The solution we went for is rolling our own version of freezing, via a _frozen attribute. Dataclasses have their own version via the frozen=True argument. Here are the pros/cons to using that:

    1. ➖ IISC, dataclasses does not have a way to temporarily unfreeze an object, which means using object.__setattr__ as a workaround, specially in tests.
    2. ➖/➕ There's been a few minor issues when mixing slots and frozen: dataclasses: frozen dataclasses with slots' __setattr__ and __delattr__ does not work. python/cpython#105936, though this has been fixed in later versions of python 3.13 and above? If users encounter this somehow below 3.13, we may just have to ask them to upgrade, but the scenario in that issue should not happen in our library.
    3. ➕ Initializing new classes is at 3-10x faster1 if we use the dataclass approach. I had codex write a script for that. The results were surprising since I was expecting the opposite..

Personally, I'm slightly in favour of using the dataclass frozen=True approach because of the "free" performance gain.

  1. Equality comparison: Dataclasses also has its own comparison method, using compare=True, which removes the need for our manual _id_attrs logic. I currently don't see any reason why we shouldn't use their approach. The only annoyance is a little more boilerplate to write if you don't want to include a field for equality comparsion. You have to do a field(eq=False). With frozen=True, the __hash__ method will also be handled for us and will check the attributes we want it to check.

  2. __repr__: Dataclasses can also do this for us, but now that we have our own approach, which customizes the look by dropping None values and empty containers, we can just stick to using that.

  3. __slots__: One of the showstoppers previously, we no longer have that issue since we only support 3.10+.

  4. __match_args__: Since we don't have a use for them, we should disable them globally for all TelegramObjects.

  5. Some class constants will need to be annotated with ClassVar, specially the Final ones.

  6. We should mention that dataclasses.asdict and replace is not explicitly supported by our API and users should use to_dict.

  7. Replacing __slots__ with fields() when wanting to get all attributes, this means almost all of our internal TO methods and even tests would need to change.

Major details

  1. Handling of Init only fields and transform like fields:

At the moment, we have a couple of classes which call other functions in the __init__, like calling to_timedelta, or parse_sequence_arg. For these kind of classes, I propose using the metadata field in Field. This metadata will contain the function for transformation.

TO.__post_init__ will be defined on the base class which will automatically run every time after the regular __init__. It's at this time we will iterate through any metadata fields and apply the transformations.

An additional use of the metadata field is to use it for deprecated arguments / attributes, e.g. duration can be an InitVar, and then we can use a regular @property to raise warnings. The metadata field can be used to store the value in _duration for example.

Side: We can't type hint the argument and attribute differently in dataclasses, so I propose to make the Sequence accepting arguments as tuple, since users are more likely to access attributes rather than initializing a class with them.

  1. Inheritance: This is still a problem as mentioned in [DISCUSSION] Dataclasses #2698 (comment).
    Possible solutions:

    i. The best solution imo is still making the defaulted inherited field as keyword only. This means that _BaseMedium, _BaseThumbedMedium, etc would make all of its default fields as kw only, which in turn changes the signature of classes which inherit them.
    This is of course a breaking change without a deprecation period, but the stability policy does outline an extreme case where we can't provide compatibility. Moreover, most of these classes are not typically instantiated by users.

    ii. Non-breaking: Override the __init__ generated from the dataclass, define a static list of positional arguments expected, and then check the passed arguments to check if they were passed positionally in the right order. I had GPT 5.5 do this, but I felt it was too black magicky and was over ~100 lines. It could work, but it can also be a maintainence burden if it's buggy.

    iii. Inline inherited arguments: This is basically removing the internal classes and all of its attributes and copying them over to the subclasses. That way we can control what order the class is instantiated with. Not a big fan of this either..


There could be 2nd order effects which I haven't forseen so far, but this should be the bulk of the changes.

What does the dev team feel about this?

Footnotes

  1. Benched on my uv installed python 3.14.6. ↩

Activity

  1. added this to the v23 milestone on Jun 26, 2026
  2. updated Effort High and Priority High on Jun 26, 2026
  3. aelkheir commented on Jul 16, 2026

    @aelkheir
    Member

    Appreciate the nice summary. brought me up to speed. Here are my thoughts

    ➖ IISC, dataclasses does not have a way to temporarily unfreeze an object, which means using object.__setattr__ as a workaround, specially in tests.

    Had an idea to keep our _unfrozen context manager then swap self.__setattr__ with object.__setattr__ and swap back again after yield.

    TO.__post_init__ will be defined on the base class which will automatically run every time after the regular init. It's at this time we will iterate through any metadata fields and apply the transformations.

    That works, but it would be nice if the generated __init__ can single shot all the parameters (transformation wise). To do that i think we can move the transformations to de_json (#5186) such that by time __init__ is called all the arguments are in the desired shape. datetime.dates are already handled that way currently. That, combined with field(default_factory=tuple) i think all of our current transformations can be handled.

    To add to this, while skimming , I came across these recalculations we do in __init__:

        class InputMediaVideo(InputMedia)
        def __init__(
            self,
            media: "FileInput | Video",
            width: int | None = None,
            height: int | None = None,
            duration: TimePeriod | None = None,
            ...
            *,
            api_kwargs: JSONDict | None = None,
        ):
            if isinstance(media, Video):
                width = width if width is not None else media.width
                height = height if height is not None else media.height
                duration = duration if duration is not None else media._duration
                media = media.file_id
            else:
                media = parse_file_input(media, attach=True, local_mode=True)

    Anything similar to that can be handled in __post_init__ on with the new _unfrozen.

    An additional use of the metadata field is to use it for deprecated arguments / attributes, e.g. duration can be an InitVar, and then we can use a regular @property to raise warnings. The metadata field can be used to store the value in _duration for example.

    I couldn't follow this to the end, how can the metadata field "be used to store the value in _duration" , aren't metadata reading-only mappingproxy.
    Wouldn't it suffice to split any deprecated argument to an arg: InitVar and _arg = field(init=False), then unfreeze to set _arg = arg in __post_init__ then expose a property @arg as you said.

    Side: We can't type hint the argument and attribute differently in dataclasses, so I propose to make the Sequence accepting arguments as tuple, since users are more likely to access attributes rather than initializing a class with them.

    👍

    This made me notice another hurdle; in some other cases (e.g duration, media from above) we genuinely want different types for each.

    Inheritance

    i. The best solution imo is still making the defaulted inherited field as keyword only.

    I really don't like this option because:
    It puts a burden on a PTB user of remembering/knowing which classes can be used positionally vs. ones with keyword-only params. Its not a huge burden, yes, because as mentioned in previous threads its not super common to be manually creating TG objects. But still the inconsistency exist.

    And its not only across classes, even in a single classes there will be line split where params above it are positional, and below are keyword-only which can be required or optional. The thing that determines how a parameter can be used (positional v.s keyword-only) is purely our inheritance hierarchy. Users will have to constantly do Shit/Cmd+K peek documentation to figure out the kw-only args. Which is not a good experience especially when their required kw-only args

    Update: This is not the case, dataclasses are intelligent enough to put only the fileds marked with keyword-only after the *. So i only have classes-wide inconsistency argument against this option. Which makes me less as much against it hounesly, but still like the inline inherited arguments better (see below)

    If we ever want to go this way, we can also consider if we want to make everything-everywhere keyword-only. Its even a bigger braking change, but more future proof. I've seen this pattern in david-lev/pywa a Whatsapp Cloud API library that uses dataclasses for thier types.

    iii. Inline inherited arguments: This is basically removing the internal classes and all of its attributes and copying them over to the subclasses. That way we can control what order the class is instantiated with. Not a big fan of this either..

    Moving the default arguments would mean to rob InputMedia of these arguments, which strictly speaking is more in line with the API (where InputMedia doesn't have any attributes at all) but still feels wrong to me.

    So yes i agree it's not only unlikable to stirp those parent classes of params, we also break code that used to rely on creating child-like instances using the parent __init__ which was a nice feature, for example:
    medias- = InputMedia(file.type, file.id, file.caption) for file in files

    However I'm personally still in favor of this robbery :), two options come to mind:

    • We keep only the required params in parents, which will be just type for the majority of the classes and a type + one or two params in InputMedia, some other parents; and move all optionals to children
    • We say screw inheritance completely and put everything in children. Parents exist for documentation purposes only.

    I'm more in favor of the first option, although the second will make it clear that parents cannot be used to create usable child-like instance like before.
    Either way, my reasoning is that since the move to dataclasses will already help us get rid of a lot of boilerplate, it does not hurt to have some boilerplate, especially that i expect the no/minimal inheritance one to be really small.

    Anyways, both you and Hinrich agree on not liking that.. any other reason than the code repitition?

  4. AAraKKe commented on Jul 16, 2026

    @AAraKKe

    I am not sure about the full history behind why a more robust deserialization is not done, but, why is PTB not using Pydantic to represent objects received by the Telegram API as well as to represents the objects to send to the API? Most of the back and forth I have seen when dealing with the internals of PTB while working on my bot was around serialization and deserialization. Pydantic excels as this and it is pretty fast on doing that as well.

    Any reason why all models have not been migrated to pydantic Models? Is a POC on that something you guys would be open to?

  5. Poolitzer commented on Jul 17, 2026

    @Poolitzer
    Member

    We are quite hesitant regarding more libraries we depend on. You claim it would increase the perfomance of PTB?

  6. AAraKKe commented on Jul 17, 2026

    @AAraKKe

    I don't have numbers to back that up, but all serialization and deserialization from plain json is done through a rust layer which includes input validation as well. Today Pydantic has become one of the most used library in APIs to read/write json responses. It is what fuels FastApi model handling (you probably already know this, just in case you didn't).

    What kind of benchmarks would you be interested in? I can try and do a POC in my spare time if it helps.

  7. harshil21 commented on Jul 21, 2026

    @harshil21
    MemberAuthor

    I just stumbled upon https://typing.python.org/en/latest/spec/dataclasses.html (I never even knew that typing.python.org existed!). This page has very useful functionality we can use:

    1. The field_specifiers lets us define our own version of dataclasses.field, so we can e.g. have a tg_field() which just calls dataclasses.field internally, but with compare=False, so we don't need to write that out for every field which doesn't need equality comparison. I suppose we could've done that with functools.partial, but it wouldn't be that supported by type checkers?

    2. The converter specification seems to solve the challenge of type hinting the argument and attribute differently, so we can do things like:

    @dataclass_transform(field_specifiers=(tg_field,))
    def tg_dataclass(cls: type[T]) -> type[T]:
        return dataclass(
            frozen=True,
            slots=True,
            repr=False,
            match_args=False,
        )(cls)
    
    def _parse_media(value: FileInput | Video) -> str | InputFile:
        ...
    
    @tg_dataclass
    class SomeMedia:
        media: str | InputFile = tg_field(converter=_parse_media)
        # similarly for parse_sequence_arg

    That being said, this is just a typing thing, and we still need runtime logic in tg_field and/or TO.__post_init__ to actually call that converter (or as we call it "transform").

    This should also solve this:

    This made me notice another hurdle; in some other cases (e.g duration, media from above) we genuinely want different types for each.


    Had an idea to keep our _unfrozen context manager then swap self.setattr with object.setattr and swap back again after yield.

    Just benched this, unfortunately doing a reassignment of that doesn't work either and still calls the original dataclass implementation. It is possible to do a class-wide reassignment, but that's about 6x slower than just doing a direct object.setattr call for me. Having a wrapper function like _set which just calls object.setattr is also about 1.4x slower.

    Another idea: We can have a flag in the module which detects when pytest is being used, e.g. something along the lines of:

    _FROZEN = "pytest" not in sys.modules
    
    @dataclass(frozen=_FROZEN, slots=True)
    class Test:
        ...

    That _FROZEN variable can also be a function for more advanced checks so we can still test if classes are actually frozen for example.

    That works, but it would be nice if the generated init can single shot all the parameters (transformation wise). To do that i think we can move the transformations to de_json (#5186) such that by time init is called all the arguments are in the desired shape. datetime.date`s are already handled that way currently.

    This would mean that transformations are only done when doing a de_json and not when manually instantiating? Don't we want to keep parse_sequence_arg?

    That, combined with field(default_factory=tuple) i think all of our current transformations can be handled

    Does that mean you want to drop doing the conversion? That would mean no tuple conversion as we document it to be:

    @dataclass
    class Example:
        values: tuple[int, ...] = field(default_factory=tuple)
    
    Example().values            # ()
    Example([1, 2]).values    # [1, 2]
    Example(None).values    # None

    Wouldn't it suffice to split any deprecated argument to an arg: InitVar and _arg = field(init=False), then unfreeze to set _arg = arg in post_init then expose a property @arg as you said.

    This is fine for now I suppose, I was thinking of too much of a fancy way to handle this to follow the DRY principle. For an first-pass implementation to dataclasses, it's okay to have some boilerplate.

    I'm more in favor of the first option, although the second will make it clear that parents cannot be used to create usable child-like instance like before

    I'm fine with either, but I think if we go with the 2nd option, that should also simplify the new __DE_JSON_DISPATCH__ logic, in the sense that that won't be needed at all, so all that logic can also be deleted.

    any other reason than the code repitition?

    Not for me anyway. I was thinking about the _ChatBase thing and how everything would to be duplicated (including the methods?!) for ChatFullInfo, and thought it could not be worth it. Maybe there still is another way to avoid that duplication for that case.

  8. harshil21 commented on Jul 21, 2026

    @harshil21
    MemberAuthor

    @AAraKKe I have no doubts that pydantic would be faster end to end, I've previously also looked at other libs like msgspec which is even faster than pydantic (or so they claim).

    It's just that adding another dependency increases supply chain risk and may not have a clean way to support all the features we want (e.g. see #2698). It would also negate all work done so far. That's not to say we'll never add a lib like pydantic, just that we might find alternate ways to speed up certain processes in the future (e.g. custom serialization support).

  9. aelkheir commented on Aug 13, 2026

    @aelkheir
    Member

    https://typing.python.org/en/latest/spec/dataclasses.html

    Cool stuff.

    This would mean that transformations are only done when doing a de_json and not when manually instantiating?

    Does that mean you want to drop doing the conversion? That would mean no tuple conversion as we document it to be:

    Yeah that was an oversight from me 😅 . We surely want transformation when manually instantiating. My default_factory=tuple thinking was assuming that an explicitly passed argument would already be a tuple after comeing through de_json so it only handled the case Example().values # ()

    that should also simplify the new __DE_JSON_DISPATCH__ logic, in the sense that that won't be needed at all, so all that logic can also be deleted.

    Interesting, but something still has to inspect the annotation and choose the concrete class right? and how would we deal with future compatibility if/when Telegram adds a new child.

    Another oversight, no inheritance at all seems easier when dealing with input only types, but with types that are returned from API we'll have to answer these questions especially the one about future compatibility.

    _FROZEN = "pytest" not in sys.modules
    @dataclass(frozen=_FROZEN, slots=True)

    hmm feels a bit off to me. the typing specification says the parameters need to be "statically evaluated". I tried this:

    import sys
    from dataclasses import dataclass
    
    _FROZEN = "pytest" not in sys.modules
    
    @dataclass(frozen=_FROZEN)
    class Foo:
        value: int

    mypy v1.15.0 reports:

    main.py:7: error: "frozen" argument must be a True or False literal  [literal-required]
    Found 1 error in 1 file (checked 1 source file)

    For me I'd prefer keeping frozen=True and making the test mutations explicit

    doing a reassignment of that doesn't work.. that's about 6x slower.. is also about 1.4x slower

    Thanks for benching this, that's sad :( I honestly don't know what to do about temp unfreezing in a declarative way, maybe we can live with the 1.4x (?) doesn't seem very bad. Anyway I think we can iterate on this along the way.


    Okay now I think I'll start working on this, and figure out the nitty-gritty details along the way. Just a quick summary of my outline:
    Dataclasses helpers:
    https://typing.python.org/en/latest/spec/dataclasses.html for defining a central tg_dataclass and tg_field with defaults that suit us.
    Inheritance
    I'll keep inheritance (and _DE_JSON_DISPATCH) and experiment with having parents bare (no fields) or have commonly required fields
    Transformations
    __post_init__ and metadata
    Future Stability Policy handling
    arg: InitVar and _arg = field(init=False)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Fields

    Priority

    High

    Effort

    High

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions