Python — Pythonic dataclass and ENUM (from real-world practice)
dataclass introduced in 3.7 (PEP-557), ENUM introduced in 3.4 (PEP-435) — honestly, I started using these features properly from Python 3.8! I’d like to share my own usage patterns and tips that I’ve developed while using them (I’ll mostly skip basic usage — if you’re a beginner, I don’t recommend this article!)
1. dataclass
1) Definition & Basic Usage
- The core essence of dataclass is “making class definitions concise”, and it automatically defines dunder methods like
__init__(),__repr__(),__eq__()for you. (Of course, you can also have it define comparison operators,__hash__, etc.) - The most relatable example is when you need to pass “data (Objects)” between specific layers (or external/internal, domains) on the server-side, you need to tell them “what you look like”. (Of course, if you’re a tough guy, you can just use dict all the time. ~~Just develop alone forever.~~) Especially if your company wants to try “DDD” with Python! I think this is where things get crazy
class ProductDTO:
def __init__(self, id, name, price, in_stock=True):
self.id = id
self.name = name
self.price = price
self.in_stock = in_stock
def __repr__(self):
return f"ProductDTO(id={self.id!r}, name={self.name!r}, price={self.price!r}, in_stock={self.in_stock!r})"
def __eq__(self, other):
if not isinstance(other, ProductDTO):
return NotImplemented
return (self.id, self.name, self.price, self.in_stock) == (other.id, other.name, other.price, other.in_stock)- For a very simple product DTO, having just
__init__would be enough. But basically, you need at least output and equal operations to make it meaningful. (== operation) When this piles up, it can get quite dizzying. - At this point, the natural consideration was “whether to use
Pydanticor not", but in my opinion, most cases can be handled with justdataclass. No serialization/deserialization needed, no external dependencies, (relatively) much more flexible, and faster with no overhead.
@dataclass
class ProductDTO:
id: int
name: str
price: float
in_stock: bool = True- You can set up
ProductDTOvery simply like above. Of course, for "API input/output, user input validation, data correction needs", I don't recommend using justdataclass. Rather, I think usingpydantic.dataclasses.dataclassis also a good approach. - https://docs.pydantic.dev/latest/concepts/dataclasses/ - From here on, it’s a record “full of personal opinions”. Rather than using all features, I’ve listed mostly what I’ve used a lot, things I’ve used extensively in practice.
2) mutable / immutable
dataclassis basically a "mutable" object. But for things like "simple definitions for external API responses" that "don't need to change and shouldn't change", it's better to make them "immutable" to prevent mistakes. (server configuration values, batch start configuration values, etc.)- In such cases, using
@dataclass(frozen=True)makes it a read-only object, "immutable".
@dataclass(frozen=True)
class DeliveryStatus:
order_id: str
status: str
updated_at: str
# Parsing external API response
resp = DeliveryStatus(order_id="ORD123", status="in_transit", updated_at="2025-05-01T12:00:00")
# Try to change a value?
resp.status = "delivered"
# ❌ FrozenInstanceError: cannot assign to field 'status'- Another good point is that it becomes hashable, so it can be used as dict keys or set elements. Not a good example, but continuing with the above example, you can check for the same response at
O(1)speed using memory like below!
cache = {resp: "cached"}
if resp in cache:
print("Already received this response.")- Note that it doesn’t always become hashable. If there are mutable objects in the fields, a TypeError occurs! There are ways to handle this as we go further down
3) default_factory
- Actually, this is more of a note than a tip, and it’s something you would have definitely used if you’re on Python 3.9 or higher. In other words, “when the default value is a mutable type like list, dictionary, set, you must use this.”
- What problem was there? There was an issue of “sharing values” for “mutable” objects. Actually, it’s not really an issue but rather because Python evaluates default values at “definition time”, so if a list was created when the first
dataclassobject was made, the next time it's created, it continues to share that same list object!
@dataclass
class Basket:
items: list[str] = []
a = Basket()
a.items.append("apple")
b = Basket()
print(b.items) # ['apple'] 😱 ???- So we use “default_factory”, and in 3.9 and above, if you don’t use it, a
ValueErroroccurs. ->ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory
from dataclasses import dataclass, field
@dataclass
class Basket:
items: list[str] = field(default_factory=list)
a = Basket()
a.items.append("apple")
b = Basket()
print(b.items) # [] → completely independent object4) __post_init__
- There are cases where validation is needed when creating a
dataclass. This is where you might considerpydantic, but if you really just need to validate specific fields, you can try using__post_init__().
@dataclass
class User:
name: str
age: int
email: str
def __post_init__(self):
if self.age < 0:
raise ValueError(f"❌ Age cannot be negative: {self.age}")
if "@" not in self.email:
raise ValueError(f"❌ Invalid email format: {self.email}")- You can create a
ValueErrorfor values that absolutely cannot be negative. In theDeliveryStatusexample above, if it's a value from external sources and there are values thatstatusabsolutely cannot be, this method is very useful. - This enables validation with just
dataclasswhen an external API response succeeds but the response is different from what was expected.
from datetime import datetime
from dataclasses import dataclass
@dataclass
class Event:
title: str
date: str # Input comes as str but…
def __post_init__(self):
parsed_date = datetime.fromisoformat(self.date)
object.__setattr__(self, 'date', parsed_date)
# Note: you can also access with self.date ...- You can even do “value correction”. But personally, I don’t recommend doing value correction itself in
__post_init__, it was meaningful for "whitespace removal" and whendateinevitably comes as a string but needs to be cast todatetimelike in the above example. - The above example didn’t use
self.datebecause it's not possible whenfrozen=True!
5) asdict / astuple
- When using for
DTOor very simple object data specification, I used this when "serialization" was needed. Especially for nested dataclass or when using forKafka (or other message queue)!
from dataclasses import dataclass, asdict
@dataclass
class User:
name: str
age: int
@dataclass
class Post:
title: str
author: User
p = Post(title="Hello", author=User(name="Alice", age=30))
print(asdict(p)){
'title': 'Hello',
'author': {
'name': 'Alice',
'age': 30
}
}- It automatically converts nested structures recursively to dict. Now you can
json.dumps()this! Data transmission setup for external API / server / heterogeneous systems (serializing) is complete with a click! - Note that it's not 100% safe...datetimecannot be serialized.. - On the other hand, I didn’t use
astuplemuch, but I remember using it once very meaningfully. When creating a temporary batch process (for LLM function call) and makingSQLwith "parameters" starting fromdataclass!! The example looks like below!
from dataclasses import dataclass, astuple
@dataclass
class User:
name: str
age: int
u = User(name="Alice", age=30)
print(astuple(u)) # ('Alice', 30)- You can beautifully put
('Alice', 30)intoINSERT INTO. In other words, when sequential data is needed, it can be used quite usefully. - And as another example, dict is mutable so not hashable, but tuple is possible (when there are no mutable elements inside), so very fast comparison based on hash like
if astuple(obj1) == astuple(obj2): ...is possible.
6) Comparison between data with order option!
- dataclass basically only creates
__eq__(), but if you setorder=True, it automatically adds__lt__,__le__,__gt__,__ge__as well!! This means sorting and size comparison between objects becomes possible like below.
from dataclasses import dataclass
@dataclass(order=True)
class User:
score: int
name: str
users = [
User(score=50, name="Alice"),
User(score=90, name="Bob"),
User(score=75, name="Charlie"),
]
sorted_users = sorted(users) # sorted by score- If you use
heapq(priority queue)? If you need to find min/max values?! If you need size comparison (<, > comparison)?! I recommend using this!
Note that sorting priority follows the declaration order!
@dataclass(order=True)
class Person:
sort_order: int # sorting is based on this
name: str7) Memory optimization with slots=True?!
- Note that this is
Python 3.10+only. I recently used this when creating batch processes, when analyzing user data (when fetching in bulk). The purpose is simple. When creatingdataclass, the__dict__attribute is also created. But this prevents that! (ps.__dict__is created when making python class)
from dataclasses import dataclass
@dataclass
class MyData:
x: int
y: int
d = MyData(1, 2)
d.z = 3 # dynamically add field
print(d.__dict__) # {'x': 1, 'y': 2, 'z': 3}- In the above example, you can prevent the
zattribute from being created. By the way, originally in python, to prevent this inclass,__slots__was traditionally used, and it was used like below. (I believe it's been possible since python 2.)
@dataclass
class A:
__slots__ = ['x', 'y']
x: int
y: int
z: int # ⚠️ This field is missing so AttributeError may occur- But now you can just add
slots=Trueto the decorator!
@dataclass(slots=True)
class B:
x: int
y: int
z: int # no worries, automatically handled- There’s a very strong recommendation article about this! — Usage of slots?
ps) There are definitely many more besides these, but I listed mainly those with personal opinions. Moreover, I don’t recommend things like unsafe_hash..!
2. ENUM
- Let’s look at “why” and “definition” as briefly as possible
Enumeration, enums are personally something I think is "full of focus on minimizing human error".- By forcing only “defined things” to be used as values, it’s easy to check in IDE, readability increases, type safety increases, and naturally being “constants” increases “maintainability”.
Here I want to collect things that were memorable while using enum, good use cases.
1) Literal type and ENUM in python
pythonis a rare "pure object-oriented language". (Unlike java etc., there are no primitive types.)str,inttypes are also "classes" and become instances of objects.- Excessive freedom becomes both an advantage and disadvantage, and we use types to supplement the disadvantages. (Actually, isn’t “collaboration” and “maintenance” the ultimate goal?) Python officially started supporting this with PEP 484 — Type Hints. (Of course, various approaches existed before, but looking only at the official part!)
Literalemerged in this flow. The reason is simple. As static type hinting was strengthened, basic types likestr,intcould be restricted, but type restriction allowing only specific values was impossible - PEP 586 – Literal Types
The differences with enum are as follows.
Item enum.Enum typing.Literal Definition Purpose To define a set of constants as a class and bind values with meaning To explicitly restrict that function arguments or variable values are one of specific values Runtime Existence Exists at runtime and behaves like an object Type hint only, no functionality at runtime Value Meaning Expression Each member has name and value, can give independent meaning Simply listing literal values ("A", 1, etc.) Extensibility Extensible with method addition etc. (Color.RED.hex()) Impossible, just fixed values Type Checking Tool Usage Also works with mypy, pyright etc. Only works as type hint (meaningful only to static analysis tools) Use Cases Distinguishing states, types, options (UserType.ADMIN) Restricting function argument values (Literal["asc", "desc"])
- For external API call response value restriction, you can use it freely with pydantic & dataclass, and you can define specific values most easily and quickly in function parameters, so personally I still use
Literalwell.
ps) Throughout Python 3.9~3.11, some features of the typing library became built-in, and I hardly use typing anymore.
2) Inheriting with str class
- No need to access
.valueattribute.
class Reliability(Enum):
HIGH = "high"
reliability = Reliability.HIGH
print(type(reliability)) # <enum 'Reliability'>
print(reliability) # Reliability.HIGH output
print(reliability.value) # "high" output
print(f"Reliability: {reliability.value}") # "Reliability: high" output- The above enum can be used like below.
class Reliability(str, Enum):
HIGH = "high"
reliability = Reliability.HIGH
print(reliability) # "high" output
print(f"Reliability: {reliability}") # "Reliability: high" output- Python’s multiple inheritance MRO(Method Resolution Order) applies “from left to right” with priority. That is, string representation related methods of
str(__str__,__repr__,__add__, etc.) usestr's implementation.
Disadvantages
- I used enum to restrict types but now you’re making the type multiple? you might think. With the above approach, there can be unexpected behavior in strict type checking like
isinstance!! - (ex —
isinstance(reliability, str)becomesTrue. But you can also induce this. If string comparison is needed.) - So one alternative is like below! Directly overriding
__str__!
def __str__(self):
return self.valueps) If you’re worried about memory overhead from str inheritance.. I recommend not using python..
3) Creating comparable Enum (using IntEnum)
- “String comparison” is one of the expensive operations in all languages. When making
Enumvalues in string form, there are cases where you keep doing string comparison in "comparison operations".
class Priority(Enum):
LOW = "low"
MEDIUM = "mi"
HIGH = "high"- When declared like this, you only do equality comparison centered operations like
if current_status == Priority.LOW. Moreover, if the former of the equality operator is a string, it naturally converts to string and does "string comparison operation".
class Priority(IntEnum):
LOW = 1
MEDIUM = 2
HIGH = 3- Now comparison operations are possible. Like
if current_status.value > Priority.HIGH.value, you can quickly do consistent condition checks for everything that's notHIGH. - This approach is most prominent in “logging levels”.
4) Creating bit flags with auto() function and Flag Enum
- When I don’t care about “values” but “keys” are important! you can use
auto. Actually, like the above example, for "states", "meaning, keys" are more important than "values". - auto() basically provides values starting from 1 and increasing by 1. There’s no possibility of issues from accidentally putting the same value. (It internally uses the
_generate_next_value_method. You can override this to make it increase with completely different values.)
from enum import Enum, auto
class Status(Enum):
PENDING = auto() # 1
ACTIVE = auto() # 2
INACTIVE = auto() # 3- Even when new “keys” are added, you can immediately expand without worrying about “values”. But the assigned value changes according to order and in this case it’s better not to do value comparison operations. And using this
auto(), you can useFlagenum more conveniently.
Before that, you need to understand “bit operations” to understand easily. (bit masking etc.)
| (OR): 1 if either bit is 1& (AND): 1 if both bits are 1^ (XOR): 1 if two bits are different~ (NOT): bit inversion (1→0, 0→1)<<, >> (SHIFT): move bits left/right
- What can you do with this? Like the example below, you can represent 8 states in one byte (one state value per bit can be represented). (Actually not just 8, but in terms of combinations, a total of 2⁸ = 256 different combinations can be represented.)
# Instead of these 8 variables
is_read = True
is_write = False
is_execute = True
# ...5 more
# Represent with one variable like this
permissions = 0b10100101 # represent 8 states in one byte- Of course, since the viewer is “human”, readability and maintainability are lacking. The closer you get to H/W development, the more these bit operations become “basic”. But if you use Flag by borrowing the Enum concept a bit?! You can use it like below.
# Instead of multiple if statements
if user.can_read and (user.is_admin or user.is_owner) and not user.is_banned:
# perform task...
# Process at once with bit operations
required_perm = READ | (ADMIN | OWNER) & ~BANNED
if user.permissions & required_perm == required_perm:
# perform task...- Actual
Flag Enumcan create bit flag Enum by inheriting theFlagclass like below.
from enum import Flag, auto
class Permission(Flag):
READ = auto() # 1 (binary: 001)
WRITE = auto() # 2 (binary: 010)
EXECUTE = auto() # 4 (binary: 100)
# Combined values, composite state values! -> This is really powerful!
READ_WRITE = READ | WRITE # 3 (binary: 011)
ALL = READ | WRITE | EXECUTE # 7 (binary: 111)
# Usage example
user_perm = Permission.READ | Permission.WRITE # 3
# Check permissions with bit operations
if Permission.READ in user_perm:
print("User has read permission")- The most convenient point is that following bit operations with your eyes every time is error-prone. So you can naturally mix it with
auto, and moreover "composite state values" are really powerful. - If you use this concept in communication, you can basically have data transmission efficiency and resulting cost savings. (Actually, it’s widely used in optimization perspectives.)
5) Enforcing uniqueness of Enum values (unique decorator)
Enumbasically allows duplication of "values" and accepts this as "alias".
from enum import Enum
class Color(Enum):
RED = 1
CRIMSON = 1 # alias for RED
GREEN = 2- But if uniqueness of “values” themselves is important, you can use the
@uniquedecorator!
from enum import Enum, unique
@unique
class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3
# DUPLICATE = 1 # This would cause ValueError- This shines when mixing
auto()with custom, that is, you can force prevention of unintended mistakes by puttinguniqueonauto!
from enum import Enum, auto, unique
@unique
class Status(Enum):
START = auto()
RUNNING = auto()
STOP = 1 # Possibility of ValueError!6) Creating Enum with composite values
- You can use tuples or complex objects as
Enumvalues! Actually, this is personally my most preferred form when collaborating. Especially when it's not a simple Enum but "representing state values", let's improve code readability for "humans"!
class HttpStatus(Enum):
OK = (200, "Success")
NOT_FOUND = (404, "Not Found")
SERVER_ERROR = (500, "Server Error")
def __init__(self, code, message):
self.code = code
self.message = message
status = HttpStatus.NOT_FOUND
print(f"Status: {status.code}, Message: {status.message}")Following this, you can “use it for documentation”!!
class LogLevel(Enum):
"""Enumeration class defining logging levels"""
DEBUG = 10
"""Detailed information for debugging purposes"""
INFO = 20
"""General information messages"""
WARNING = 30
"""Warning about potential problem situations"""
ERROR = 40
"""Error occurred but program execution is possible"""- How beautiful this “human” and “collaboration” oriented
Enumis!! Furthermore, it's good to look at Enum's__doc__. - https://tech.isyncbrain.com/python/enum/alias/sqlalchemy/2022/05/15/annotated-enum.html (Example using Enum__doc__with SQLAlchemy!)
7) Implementing custom lookup with _missing_ method
from enum import Enum
class Color(Enum):
RED = "red"
GREEN = "green"
BLUE = "blue"
Color("RED") # ValueError: 'RED' is not a valid Color- Actually “RED” exists (“red”). In such cases, you can customize the handling method when a value not defined in
Enumis given. Through_missing_!
class CaseInsensitiveEnum(str, Enum):
@classmethod
def _missing_(cls, value):
if isinstance(value, str):
# Find without case sensitivity
for member in cls:
if member.value.lower() == value.lower():
return member
raise ValueError(f"{value!r} is not a valid value for {cls.__name__}")
class Color(CaseInsensitiveEnum):
RED = "red"
GREEN = "green"
BLUE = "blue"
# Find values without case sensitivity
assert Color("RED") == Color("red") == Color.RED- This way, you can recognize various forms of strings like
"RED", "Red", "red"identically and map them to Color.RED. - By the way,
raise ValueErrorrather than doing things likereturn Nonein_missing_is a more correct Enum approach!
8) Customizing __str__ & __repr__
- There are quite a few cases of customizing Enum’s output format, and it’s not bad to create one base enum like below in advance and inherit from it!
class FormattedEnum(Enum):
def __str__(self):
return f"{self.name} ({self.value})"
def __repr__(self):
return f"{self.__class__.__name__}.{self.name}"
class HttpStatus(FormattedEnum):
OK = 200
NOT_FOUND = 404
ERROR = 500
print(HttpStatus.OK) # "OK (200)"
print(repr(HttpStatus.OK)) # "HttpStatus.OK"- I think this helps a lot with logging. So it was also highly useful in debugging.
ETC
ps) There are other things like Enum inheritance (actually directly shown in examples), getting all attribute values as dictionary with Enum.__members__, dynamically creating Enum functionally, etc.
Get Nuung’s stories in your inbox
Join Medium for free to get updates from this writer.
ps) Moreover, I think @property doesn't fit well with Enum which is for "immutable constants / metadata". If used incorrectly, there are plenty of side effects where "logic" gets added!
Sources
- https://peps.python.org/pep-0435/
- https://peps.python.org/pep-0484/
- https://peps.python.org/pep-0557/
- https://peps.python.org/pep-0586/
- https://docs.python.org/3/library/dataclasses.html#dataclasses.field
- https://docs.pydantic.dev/latest/concepts/dataclasses/
- https://stackoverflow.com/questions/472000/usage-of-slots










