Repository navigation
Switching to Dataclasses #5279
Description
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:
- No more slots and
__init__boilerplate - Letting dataclass machinery handle equality comparison for us
- 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
-
Immutability: The solution we went for is rolling our own version of freezing, via a
_frozenattribute. Dataclasses have their own version via thefrozen=Trueargument. Here are the pros/cons to using that:- ➖ IISC, dataclasses does not have a way to temporarily unfreeze an object, which means using
object.__setattr__as a workaround, specially in tests. - ➖/➕ 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. - ➕ 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..
- ➖ IISC, dataclasses does not have a way to temporarily unfreeze an object, which means using
Personally, I'm slightly in favour of using the dataclass frozen=True approach because of the "free" performance gain.
-
Equality comparison: Dataclasses also has its own comparison method, using
compare=True, which removes the need for our manual_id_attrslogic. 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 afield(eq=False). Withfrozen=True, the__hash__method will also be handled for us and will check the attributes we want it to check. -
__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. -
__slots__: One of the showstoppers previously, we no longer have that issue since we only support 3.10+. -
__match_args__: Since we don't have a use for them, we should disable them globally for all TelegramObjects. -
Some class constants will need to be annotated with
ClassVar, specially theFinalones. -
We should mention that
dataclasses.asdictandreplaceis not explicitly supported by our API and users should useto_dict. -
Replacing
__slots__withfields()when wanting to get all attributes, this means almost all of our internal TO methods and even tests would need to change.
Major details
- 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.
-
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
-
Benched on my uv installed python 3.14.6. ↩
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Effort
Activity
aelkheir commentedon Jul 16, 2026
Appreciate the nice summary. brought me up to speed. Here are my thoughts
Had an idea to keep our
_unfrozencontext manager then swapself.__setattr__withobject.__setattr__and swap back again after yield.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 withfield(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__:Anything similar to that can be handled in
__post_init__on with the new_unfrozen.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: InitVarand_arg = field(init=False), thenunfreeze toset_arg = argin__post_init__then expose a property@argas you said.👍
This made me notice another hurdle; in some other cases (e.g
duration,mediafrom above) we genuinely want different types for each.Inheritance
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 doShit/Cmd+Kpeek documentation to figure out the kw-only args. Which is not a good experience especially when their required kw-only argsUpdate: 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.
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 filesHowever I'm personally still in favor of this robbery :), two options come to mind:
typefor the majority of the classes and atype+ one or two params inInputMedia, some other parents; and move all optionals to childrenI'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?
AAraKKe commentedon Jul 16, 2026
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?
Poolitzer commentedon Jul 17, 2026
We are quite hesitant regarding more libraries we depend on. You claim it would increase the perfomance of PTB?
AAraKKe commentedon Jul 17, 2026
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.
harshil21 commentedon Jul 21, 2026
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:
The
field_specifierslets us define our own version ofdataclasses.field, so we can e.g. have atg_field()which just calls dataclasses.field internally, but withcompare=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 withfunctools.partial, but it wouldn't be that supported by type checkers?The converter specification seems to solve the challenge of type hinting the argument and attribute differently, so we can do things like:
That being said, this is just a typing thing, and we still need runtime logic in
tg_fieldand/orTO.__post_init__to actually call that converter (or as we call it "transform").This should also solve this:
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:
That _FROZEN variable can also be a function for more advanced checks so we can still test if classes are actually frozen for example.
This would mean that transformations are only done when doing a
de_jsonand not when manually instantiating? Don't we want to keepparse_sequence_arg?Does that mean you want to drop doing the conversion? That would mean no tuple conversion as we document it to be:
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 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.Not for me anyway. I was thinking about the
_ChatBasething and how everything would to be duplicated (including the methods?!) forChatFullInfo, and thought it could not be worth it. Maybe there still is another way to avoid that duplication for that case.harshil21 commentedon Jul 21, 2026
@AAraKKe I have no doubts that
pydanticwould be faster end to end, I've previously also looked at other libs likemsgspecwhich is even faster thanpydantic(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).
aelkheir commentedon Aug 13, 2026
Cool stuff.
Yeah that was an oversight from me 😅 . We surely want transformation when manually instantiating. My
default_factory=tuplethinking was assuming that an explicitly passed argument would already be a tuple after comeing through de_json so it only handled the caseExample().values # ()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.
hmm feels a bit off to me. the typing specification says the parameters need to be "statically evaluated". I tried this:
mypy v1.15.0 reports:
For me I'd prefer keeping frozen=True and making the test mutations explicit
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_dataclassandtg_fieldwith 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: InitVarand_arg = field(init=False)