To me, that class definition is very inefficient — in the __init__ method, you repeat each parameter at least three times. This may not sound like a big deal, but think about how often you write classes in your lifetime with many more parameters.
In comparison, take a look at the data classes alternative of the above code:
from dataclasses import dataclass
@dataclassclassExercise:
name:str
reps:int
sets:int
weight:float# Weight in lbs
This modest-looking piece of code is orders of magnitude better than a regular class. The tiny @dataclass decorator is implementing __init__, __repr__, __eq__ classes behind the scenes, which would have taken at least 20 lines of code manually.
Besides, many other features, such as comparison operators, object ordering, and immutability, are all a single line away from being magically created for our class.
So, the purpose of this tutorial is to show you why data classes are one of the best things to happen to Python if you love object-oriented programming.
Let’s get started!
Basics of Python Data Classes
Let’s cover some of the fundamental concepts of Python data classes that make the so useful.
Some methods are automatically generated in data classes
Despite all their features, data classes are regular classes that take much less code to implement the same functionality. Here is the Exercise class again:
from dataclasses import dataclass
@dataclassclassExercise:
name:str
reps:int
sets:int
weight:float
ex1 = Exercise("Bench press",10,3,52.5)# Verifying Exercise is a regular class
ex1.name
'Bench press'
And so it does! In regular classes, this logic would have been a pain to write.
Data classes require type hints
As you might have noticed, data classes require type hints when defining fields. In fact, data classes allow any type from the typing module. For example, here is how to create a field that can accept Any data type:
from typing import Any
@dataclassclassDummy:
attr: Any
Right now, each workout session instance requires exercises to be initialized. But this doesn’t mirror how people work out — first, they start a session (probably in an app), and then they add exercises as they work out.
So, we must be able to create sessions with no exercises and no duration. Let’s make this happen by adding an empty list as a default value for exercises:
@dataclassclassWorkoutSession:
exercises: List[Exercise]=[]
duration_minutes:int=None
hiit_monday = WorkoutSession("25-02-2024")
ValueError: mutable default <class'list'>for field exercises isnot allowed: use default_factory
The default_factory parameter accepts a function that returns an initial value for a data class field. This means that it can accept any arbitrary function:
tuple
dict
set
Any user-defined custom function
This is accurate regardless of whether the result of the function is mutable or not.
Now, if we think about it, most people start their training with warm-up exercises that are typically similar for any kind of workout. So, initializing sessions with no exercises may not be what some people want.
Instead, let’s create a function that returns three warm-up Exercises:
Now, any time we create a session, they will come with some warm-up exercises already logged. The new version of WorkoutSession has a default duration of five minutes to account for that.
Adding methods to data classes
Since data classes are regular classes, adding methods to them stays the same. Let’s add two methods to our WorkoutSession data class:
The class’ spring representation is much nicer. Now, let’s fix WorkoutSession as well:
@dataclassclassWorkoutSession:
exercises: List[Exercise]= field(default_factory=create_warmup)
duration_minutes:int=5# Increase the default duration as welldefadd_exercise(self, exercise: Exercise):
self.exercises.append(exercise)defincrease_duration(self, minutes:int):
self.duration_minutes += minutes
def__str__(self):
base =""for ex in self.exercises:
base +=str(ex)+"\n"
base +=f"\nSession duration: {self.duration_minutes} minutes."return base
hiit_monday = WorkoutSession()print(hiit_monday)
Jumping jacks:30/1
Squat lunges:10/2
High jumps:20/1
Session duration:5 minutes.
Note: Use the “Explain code” button at the bottom of the snippet to get a line-by-line explanation of the code.
Now, we have got a readable and compact output.
Comparison in data classes
For many classes, it makes sense to compare their objects by some logic. For workouts, it can be the workout duration, the exercise intensity or the weight.
First, let’s see what happens if we try to compare two workouts in the current state:
hiit_wednesday = WorkoutSession()
hiit_wednesday.add_exercise(Exercise("Pull-ups",7,3))print(hiit_wednesday)
Jumping jacks:30/1
Squat lunges:10/2
High jumps:20/1
Pull-ups:7/3
Session duration:5 minutes.
hiit_monday > hiit_wednesday
TypeError:'>'not supported between instances of 'WorkoutSession'and'WorkoutSession'
This time, comparison works, but what are we even comparing?
In data classes, comparison is performed in the order in which the fields are defined. Right now, the classes are compared based on workout duration since the first field, exercises, contains non-standard objects.
We can verify this by increasing the duration of the Wednesday session:
Even though the Monday session lasts longer, the comparison tells us that it is smaller than Wednesday. The reason is that “25” comes before “27” in Python string comparison.
So, how do we keep the order of the fields and still sort sessions based on the workout duration? This is easy through the field function:
By setting compare to False for any field, we exclude it from sorting, as evidenced by the above result.
Post-init field manipulation
Right now, we have a default session duration of five minutes to account for warm-up exercises. However, this only makes sense if a user starts a session with a warm-up. What if they start a session with other exercises:
For just a single exercise, the total duration is five minutes, which is illogical. Each session must dynamically guess its duration based on the number of sets of each exercise. This means we should make duration_minutes dependent on the exercises field.
Let’s implement it:
@dataclassclassWorkoutSession:
exercises: List[Exercise]= field(default_factory=create_warmup)
duration_minutes:int= field(default=0, init=False)def__post_init__(self):
set_duration =3for ex in self.exercises:
self.duration_minutes += ex.sets * set_duration
...
We want to protect all fields of our classes so that they can be modified only in a way we want. To accomplish this, the @dataclass decorator offers a convenient frozen argument:
Setting frozen to True automatically adds __deleteattr__ and __setattr__ methods for each field so that they are protected from deletion or updates after initialization. Also, others won't be able to add new fields as well:
ex1.new_field =10
FrozenInstanceError: cannot assign to field 'new_field'
However, exercises is a list, which is fully mutable, making the following operation possible:
# Changing one of the elements in a list
# Changing one of the elements in a list
session1.exercises[1]= FrozenExercise("Totally new exercise",5,5)print(session1)
ImmutableWorkoutSession(exercises=[Exercise(name='Jumping jacks', reps=30, sets=1, weight=0), FrozenExercise(name='Totally new exercise', reps=5, sets=5, weight=0), Exercise(name='High jumps', reps=20, sets=1, weight=0)], duration_minutes=5)
But, since the last field in the parent class (ImmutableWorkoutSession) has a default value, all fields in child classes must have default values.
For example, this is not allowed:
@dataclass(frozen=True)classImmutableWorkoutSession:
exercises: List[Exercise]= field(default_factory=create_warmup)
duration_minutes:int=5@dataclass(frozen=True)classCardioWorkoutSession(ImmutableWorkoutSession):
intensity_level:str# Not allowed, must have a default
TypeError: non-default argument 'intensity_level' follows default argument
Disadvantages of Data Classes and Further Resources
Data classes have been steadily improving since Python 3.7 (they were great to begin with) and cover many use cases where you might need to write classes. But they might be disadvantageous in the following scenarios:
Custom __init__ methods
Custom __new__ methods
Various inheritance patterns
And many more, as discussed in this great Reddit thread. If you want a more detailed rationale for why data classes were introduced and why they aren’t drop-in replacements to regular class definitions, read PEP 557.
If you are interested in object-oriented programming in general, here is a course to continue your journey:
Fundamentally, data classes are fancier structures to hold and retrieve data more efficiently. However, Python has many other data structures that perform this task more or less in a similar manner. For example, you can learn about counters, defaultdicts and namedtuples in the last chapter of the Data Types for Data Science course.
Bex Tuychiev builds voice agents and LLM systems, and most of his work goes into making them reliable enough to run in production, preferably in healthcare.
In Python, everything is an object. Numbers, strings, DataFrames, even functions are objects. In particular, everything you deal with in Python has a class, a blueprint associated with it under the hood.
Data classes are one of the features of Python that, after you discover them, you are never going back to the old way. Consider this regular class:
To me, that class definition is very inefficient — in the
__init__method, you repeat each parameter at least three times. This may not sound like a big deal, but think about how often you write classes in your lifetime with many more parameters.In comparison, take a look at the data classes alternative of the above code:
This modest-looking piece of code is orders of magnitude better than a regular class. The tiny
@dataclassdecorator is implementing__init__,__repr__,__eq__classes behind the scenes, which would have taken at least 20 lines of code manually.Besides, many other features, such as comparison operators, object ordering, and immutability, are all a single line away from being magically created for our class.
So, the purpose of this tutorial is to show you why data classes are one of the best things to happen to Python if you love object-oriented programming.
Let’s get started!
Basics of Python Data Classes
Let’s cover some of the fundamental concepts of Python data classes that make the so useful.
Some methods are automatically generated in data classes
Despite all their features, data classes are regular classes that take much less code to implement the same functionality. Here is the
Exerciseclass again:Right now,
Exercisealready has__repr__and__eq__methods already implemented. Let's verify it:The object representation of an object
reprmust return the code that can recreate itself, and we can see that is exactly the case forex1.In comparison,
Exercisedefined in the old way would look like this:Looks pretty awful and useless!
Now, let’s verify the existence of
__eq__, which is the equality operator:Comparing the class to itself and to another class with identical parameters must return True:
And so it does! In regular classes, this logic would have been a pain to write.
Data classes require type hints
As you might have noticed, data classes require type hints when defining fields. In fact, data classes allow any type from the
typingmodule. For example, here is how to create a field that can acceptAnydata type:However, the idiosyncrasy of Python is that even though data classes require type hints, types aren’t actually enforced.
For example, creating an instance of
Exerciseclass with completely incorrect data types can be run without errors:If you want to enforce data types, you must use type checkers such as Mypy.
Data classes allow default values in fields
Until now, we haven’t added any defaults to our classes. Let’s fix that:
Keep in mind that non-default fields can’t follow default fields. For example, the below code will throw an error:
In practice, you will rarely define defaults with
name: type = valuesyntax.Instead, you will use the
fieldfunction, which allows more control of each field definition:The
fieldfunction has more parameters, such as:reprinitcomparedefault_factoryand so on. We will discuss these in the coming sections.
Data classes can be created with a function
A final note on the data class basics is that their definition can be even shorter by using the
make_dataclassfunction:But you will sacrifice readability, so I don’t recommend using this function.
Advanced Python Data Classes
In this section, we will discuss advanced features of data classes that bring more benefits. One such feature is a default factory.
Default factories
To explain default factories, let’s create another class named
WorkoutSessionthat accepts two fields:By using the
Listtype, we are specifying thatWorkoutSessionaccepts a list ofExerciseinstances.Right now, each workout session instance requires exercises to be initialized. But this doesn’t mirror how people work out — first, they start a session (probably in an app), and then they add exercises as they work out.
So, we must be able to create sessions with no exercises and no duration. Let’s make this happen by adding an empty list as a default value for
exercises:However, we got an error — it turns out data classes don’t allow mutable default values.
Thankfully, we can fix this by using a default factory:
The
default_factoryparameter accepts a function that returns an initial value for a data class field. This means that it can accept any arbitrary function:tupledictsetThis is accurate regardless of whether the result of the function is mutable or not.
Now, if we think about it, most people start their training with warm-up exercises that are typically similar for any kind of workout. So, initializing sessions with no exercises may not be what some people want.
Instead, let’s create a function that returns three warm-up
Exercises:Now, any time we create a session, they will come with some warm-up exercises already logged. The new version of
WorkoutSessionhas a default duration of five minutes to account for that.Adding methods to data classes
Since data classes are regular classes, adding methods to them stays the same. Let’s add two methods to our
WorkoutSessiondata class:Using these methods, we can now log any new activity to a session:
But there is a problem:
When we print the session, its default representation is too verbose and unreadable since it contains the code to recreate the object. Let’s fix that.
__repr__and__str__in data classesData classes implement
__repr__automatically but not__str__. This makes the class fall back on__repr__when we call print on it.So, let’s override this behavior by defining
__str__on our own:The
__repr__is still the same, but when we callprinton it:The class’ spring representation is much nicer. Now, let’s fix
WorkoutSessionas well:Note: Use the “Explain code” button at the bottom of the snippet to get a line-by-line explanation of the code.
Now, we have got a readable and compact output.
Comparison in data classes
For many classes, it makes sense to compare their objects by some logic. For workouts, it can be the workout duration, the exercise intensity or the weight.
First, let’s see what happens if we try to compare two workouts in the current state:
We receive a
TypeErroras data classes don't implement comparison operators. But this is easily fixable by setting theorderparameter toTrue:This time, comparison works, but what are we even comparing?
In data classes, comparison is performed in the order in which the fields are defined. Right now, the classes are compared based on workout duration since the first field,
exercises, contains non-standard objects.We can verify this by increasing the duration of the Wednesday session:
As expected, we received
False.But what would happen if the first field of
Workoutwas another type of field, say, a string? Let's try and find out:Even though the Monday session lasts longer, the comparison tells us that it is smaller than Wednesday. The reason is that “25” comes before “27” in Python string comparison.
So, how do we keep the order of the fields and still sort sessions based on the workout duration? This is easy through the
fieldfunction:By setting
comparetoFalsefor any field, we exclude it from sorting, as evidenced by the above result.Post-init field manipulation
Right now, we have a default session duration of five minutes to account for warm-up exercises. However, this only makes sense if a user starts a session with a warm-up. What if they start a session with other exercises:
For just a single exercise, the total duration is five minutes, which is illogical. Each session must dynamically guess its duration based on the number of sets of each exercise. This means we should make
duration_minutesdependent on theexercisesfield.Let’s implement it:
This time, we are defining
duration_minuteswithinitset toFalseto delay the field's initialization.Then, inside a special method
__post_init__, we are updating its value based on the total number of sets in eachExercise.Now, when we initialize
WorkoutSession, theduration_minutesis dynamically increased by three minutes for each set in each exercise.In general, if you want to define a field that depends on other fields of your data class, you can use the
__post_init__logic.Immutability in Data Classes
Our
WorkoutSessiondata class is almost ready; it just needs to be protected. Right now, it can be messed up pretty easily:We want to protect all fields of our classes so that they can be modified only in a way we want. To accomplish this, the
@dataclassdecorator offers a convenientfrozenargument:Now, if we want to modify any field, we get an error:
Setting
frozentoTrueautomatically adds__deleteattr__and__setattr__methods for each field so that they are protected from deletion or updates after initialization. Also, others won't be able to add new fields as well:This functionality would have dozens of lines of code if we were dealing with traditional classes.
However, please note that we can’t make our classes truly immutable. For example, let’s rewrite the
WorkoutSessionwithfrozenset toTrue:As expected, we can’t directly modify the list of exercises:
However,
exercisesis a list, which is fully mutable, making the following operation possible:# Changing one of the elements in a list
So, to protect from accidental changes, it is recommended to use immutable objects such as tuples for field values.
Inheritance in data classes
One last point we will cover is the order of fields in parent and child classes.
Since data classes are regular classes, inheritance works as usual:
But, since the last field in the parent class (
ImmutableWorkoutSession) has a default value, all fields in child classes must have default values.For example, this is not allowed:
Disadvantages of Data Classes and Further Resources
Data classes have been steadily improving since Python 3.7 (they were great to begin with) and cover many use cases where you might need to write classes. But they might be disadvantageous in the following scenarios:
__init__methods__new__methodsAnd many more, as discussed in this great Reddit thread. If you want a more detailed rationale for why data classes were introduced and why they aren’t drop-in replacements to regular class definitions, read PEP 557.
If you are interested in object-oriented programming in general, here is a course to continue your journey:
Fundamentally, data classes are fancier structures to hold and retrieve data more efficiently. However, Python has many other data structures that perform this task more or less in a similar manner. For example, you can learn about counters,
defaultdictsandnamedtuplesin the last chapter of the Data Types for Data Science course.