Sitemap

Python — Pythonic dataclass and ENUM (from real-world practice)

13 min readJun 18, 2025

--

Press enter or click to view image in full size
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 Pydantic or not", but in my opinion, most cases can be handled with just dataclass. 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 ProductDTO very simply like above. Of course, for "API input/output, user input validation, data correction needs", I don't recommend using just dataclass. Rather, I think using pydantic.dataclasses.dataclass is 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

  • dataclass is 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 dataclass object 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 ValueError occurs. -> 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 object

4) __post_init__

  • There are cases where validation is needed when creating a dataclass. This is where you might consider pydantic, 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 ValueError for values that absolutely cannot be negative. In the DeliveryStatus example above, if it's a value from external sources and there are values that status absolutely cannot be, this method is very useful.
  • This enables validation with just dataclass when 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 when date inevitably comes as a string but needs to be cast to datetime like in the above example.
  • The above example didn’t use self.date because it's not possible when frozen=True!

5) asdict / astuple

  • When using for DTO or very simple object data specification, I used this when "serialization" was needed. Especially for nested dataclass or when using for Kafka (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... datetime cannot be serialized..
  • On the other hand, I didn’t use astuple much, but I remember using it once very meaningfully. When creating a temporary batch process (for LLM function call) and making SQL with "parameters" starting from dataclass!! 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) into INSERT 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 set order=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: str

7) 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 creating dataclass, 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 z attribute from being created. By the way, originally in python, to prevent this in class, __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=True to 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

  • python is a rare "pure object-oriented language". (Unlike java etc., there are no primitive types.) str, int types 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!)
  • Literal emerged in this flow. The reason is simple. As static type hinting was strengthened, basic types like str, int could 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 Literal well.

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 .value attribute.
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.) use str'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) becomes True. 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.value

ps) 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 Enum values 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 not HIGH.
  • 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 use Flag enum more conveniently.

Before that, you need to understand “bit operations” to understand easily. (bit masking etc.)

  1. | (OR): 1 if either bit is 1
  2. & (AND): 1 if both bits are 1
  3. ^ (XOR): 1 if two bits are different
  4. ~ (NOT): bit inversion (1→0, 0→1)
  5. <<, >> (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 Enum can create bit flag Enum by inheriting the Flag class 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)

  • Enum basically 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 @unique decorator!
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 putting unique on auto!
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 Enum values! 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 Enum is!! 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 Enum is 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 ValueError rather than doing things like return None in _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!

--

--

Written by Nuung

I believe development should never come before business. That’s why I dive deep into domains and refine my craft—to build solutions that truly matter.