← Back to list

How Python structures data set?

TypedDict, namedtuple, Dataclass

SleeperShark · 2025-06-29 11:53 · 0 claps · 21.2 min read
#python #python-programming #intermediate-python
Open on Medium ↗
Wiki topics: 💻 · Programming

How Python structures data?

TypedDict, namedtuple, Dataclass

TL;DR

  • **TypedDict** - 給 dictionary 的 Type Annotation (not data container)
  • 搭配 Type Checker 檢查
  • 提供 value type info 與 Type Hint 效果。
  • **namedtuple** - 透過 named attributes access 的方式取得資料的 tuple
  • 最輕量的 data container,空間優化首選
  • **dataclass - 快速搭建 class** schema blueprint
  • 彈性、擴充性最好的 data container
  • 其他提供 schema + validation + serialization 功能的 third-party packages: attrs, msgspec, marshmallow, pydantic

Photo by Rubaitul Azad on Unsplash

Photo by Rubaitul Azad on Unsplash

在很多開發情境中,常常會拿到ㄧ個序列的 “資料組 data set”,而資料組的各個欄位則是由資料組的 order 來決定。

比如說,透過 csv.reader 取得的 row data,或是 sqlite3 的 cursor.execute 取得的資料列,都是用 iterable 的資料結構來儲存,而具體的欄位對應的資料,則是根據 csv header / SELECT 語句的內容來決定。

如果直接以這些原始結構進行操作,將難以理解實際上 row 資料各個順序的意涵;而解方則是透過一些 data structure 的包裝,透過 field name 的方式取代原本的 iterable order,明文定義 field 與 value 之間的關係。

以下快速介紹 3 個 Python 原生的 module,讓開發者可以快速的包裝 data row,除了增加資料的透明性,且會在 IDE 上提供友善的 Type Hint,大大優化開發上的體驗。

TypedDict

首先先從約束力最低的 TypedDict 開始說。

從 module 就可以知道,[typing.TypedDict](https://typing.python.org/en/latest/spec/typeddict.html) 其實連 data container 都不是,僅僅是一個對 dictionary 的型別注釋 (Type Annotation) 而已。

因此,一如其他 Python 的 typing,即使傳入的參數不符合 TypedDict 定義,執行上也不會報錯

from typing import TypedDict

class User(TypedDict):
    name: str
    age: int

def print_user(user: User):
    print(user)

user: User = {"name": "Alex"}

print_user(user) 
# {'name': 'Alex'}

# 即使 user 變數不符合 User 定義,依然可以運行

TypedDict 基本上的應用情境如下:

  • 搭配 Python Type Checker (如 MyPy, Pylance) 做型別檢查。 (其實 TypedDict 本來就源自於 MyPy 套件型別)
  • 增加 dictionary data 的可讀性。
  • 在編輯器中提供 Type Hint,減少 hard-coded key 的錯誤。

整理官方文件中實用的情境:

from typing import TypedDict

# ===== Basic usage =====
class Movie(TypedDict):
    name: str
    year: int

# Used as dictionary typing
movie_1: Movie = {"name": "Titanic", "year": 1997}

# Or use it as a dictionary constructor
movie_2 = Movie(name="Blade runner", year=1982)
# type of movie_2 is still dict
print(type(movie_2)) # <class 'dict'>

# ===== Inheritance =====
class BookBaseMovie(Movie):
    based_on: str
    # This class posses 3 keys: name, year, based_on

# ===== Totality =====
class Movie(TypedDict, total=False):    # `total` default to True
    name: str
    year: int

m = Movie(name="Zootopia")  # No type check error

# ===== Required and NotRequired =====
from typing import Required, NotRequired

# Use `Required` when total is False
class Movie(TypedDict, total=False):
    title: Required[str]
    year: int

# Use `NotRequired` when total is True
class Movie(TypedDict):
    title: str
    year: NotRequired[int]

# ===== Interaction with Annotated[] =====
from typing import Annotated

class Movie(TypedDict):
    title: str
    year: NotRequired[Annotated[int, lambda value: value > 1800]]

# OR
class Movie(TypedDict):
    title: str
    year: Annotated[NotRequired[int], lambda value: value > 1800]

# ===== Read-only Items =====
# NOTE: `ReadOnly` qualifier was added in 3.13
from typing import ReadOnly

class Band(TypedDict):
    name: str
    members: ReadOnly[list[str]]

blur: Band = {"name": "blur", "members": []}
blur["members"] = ["Damon Albarn"]  # Type check error: "members" is read-only

# Can be implemented with Required / NotRequired
class OptionalName(TypedDict):
    name: ReadOnly[NotRequired[str]]

namedtuple

[collections.namedtuple](https://docs.python.org/3/library/collections.html#collections.namedtuple) 可以理解成 tuple 的加強版,除了原本的 immutable, indexing 的特性外,named attribute 的功能讓開發者可以透過 dot 的方式取得資料。 當然,IDE 也會貼心的提供 type hint 做參考。

from collections import namedtuple

Point = namedtuple('Point', ['x', 'y'])

def print_point(p: Point):
    print(p)                      # Point(x=1, y=1)
    print(type(p))                # <class '__main__.Point'>
    print(isinstance(p, tuple))   # True
    print(isinstance(p, Point))   # True
    print(p[0])                   # 1
    print(p[1])                   # 2
    print(p.x)                    # 1
    print(p.y)                    # 2

print_point(Point(x=1, y=2))

namedtuple 的最大優點是空間效能

在每一個 Python 物件中,會有存有一個負責管理 attributes 資料的 instance dictionaries。 除了預先在 class 中定義好的 attributes 以外,可以隨性地透過 dot 或是 setattr 的方式儲存任意 attribute。

可以透過 instance.__dict__ 來查看當前物件的 attributes:

class ClassPoint:
    def __init__(self, x, y):
        self.x = x
        self.y = y

point = ClassPoint(x=1, y=2)
point.z = 3
print(point.__dict__)
# {'x': 1, 'y': 2, 'z': 3}

而 tuple (以及namedtuple) 透過定義 __slots__ property 來規範物件的 attributes。 雖然不能再任意增加 attribute,但因為無需生成 instance dictionary,大幅減少 memory 的使用。

(參考:https://medium.com/@apps.merkurev/dont-forget-about-slots-in-python-c397f414c490

from collections import namedtuple

TuplePoint = namedtuple('Point', ['x', 'y'])
point = TuplePoint(x=1, y=2)

print(point.__dict__)
# raise AttributeError: 'tuple' object has no attribute '__dict__'.

pympler 簡單比較用 list, dictionary, tuple, namedtuple 與 class instance 來儲存 x, y, z 三點座標的 memory usage:

from pympler import asizeof
from collections import namedtuple

class ClassPoint:
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z

TuplePoint = namedtuple('Point', ['x', 'y', 'z'])

print(f'tuple: {asizeof.asizeof((1, 2, 3))} bytes')
print(f'list: {asizeof.asizeof([1, 2, 3])} bytes')
print(f'dict: {asizeof.asizeof(dict(x=1, y=2, z=3))} bytes')
print(f'namedtuple: {asizeof.asizeof(TuplePoint(x=1, y=2, z=3))} bytes')
print(f'instance: {asizeof.asizeof(ClassPoint(x=1, y=2, z=3))} bytes')

# tuple: 160 bytes
# list: 184 bytes
# dict: 424 bytes
# namedtuple: 160 bytes
# instance: 584 bytes

整理官方文件中實用的情境:

from collections import namedtuple

# ===== Basic usage =====
Point = namedtuple('Point', ['x', 'y'])

# Or, construct with class, which allows you to add or change functionality
class Point(namedtuple('Point', ['x', 'y'])):
    __slots__ = ()  # prevent the creation of instance dictionaries

    @property
    def hypot(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

    def __str__(self):
        return 'Point: x=%6.3f  y=%6.3f  hypot=%6.3f' % (self.x, self.y, self.hypot)

p = Point(x=1, y=2)

# provide concise default __repr__
print(p)
# Point(x=1, y=2)

# provide concise default dcoumentation
print(Point.__doc__)
# Point(x, y)

# yet can customize doc by making direct assignments:
Point.__doc__ += ': A 2D-point coordinate.'
Point.x.__doc__ = 'Point X (integer)'

# ===== default values =====
# `defaults` will take the values as kwargs
Account = namedtuple(
    'Account',
    ['type', 'user', 'balance'],
    defaults=['Anonymous', 0],
)

a1 = Account(type='premium', user='Terry')
a2 = Account(type='ordinary', balance=200)

print(a1)  # Account(type='premium', user='Terry', balance=0)
print(a2)  # Account(type='ordinary', user='Anonymous', balance=200)

# ===== namedtuple._make =====
p_data = (
    (1, 2),
    (3, 5),
    (9, 1),
)
points = list(map(Point._make, p_data))
# list of Point obj

# ===== namedtuple._asdict =====
# useful for serialization
print(Point(x=1, y=2)._asdict())  # {'x': 1, 'y': 2}

# ===== namedtuple._replace =====
# Return a new instance of the named tuple replacing specified fields
# with new values
print(Point(x=1, y=1)._replace(x=1000)) # Point(x=1000, y=1)

# ===== namedtuple._fields =====
# Useful for introspection and for creating new named tuple types
# from existing named tuples
Point3D = namedtuple('Point3D', Point._fields + ('z',))
print(Point3D(x=1, y=2, z=3)) # Point3D(x=1, y=2, z=3)

Dataclass

dataclasses.dataclass decorator 是一個在定義 class schema 的語法糖,從文件中可以得知,在 class definition 上加上 @dataclass 後,Python 會自動幫你定義好幾個 special methods (或稱 dunder methods):

The @dataclass decorator will add various “dunder” methods to the class… and returns the same class that it is called on.

因此,本質上 dataclass 與 custom Class 是一樣的效果,只是省略了一些繁瑣的 function definition,同時 dataclass 也提供了一些額外的功能。

以大家最熟悉的例子:__init__ 來說,加上@dataclass 後,就不用手動一個一個在 function 定義 params、又要再一個一個 assign 到 self 上:

from dataclasses import dataclass

@dataclass
class InventoryItem:
    name: str
    unit_price: float
    quantity_on_hand: int

# Above is equivalent to:
class NormalInventoryItem:
    name: str
    unit_price: float
    quantity_on_hand: int

    # dataclass will take care of the passed arguments
    # and assignments in __init__ method
    def __init__(self, name, unit_price, quantity_on_hand):
        self.name = name
        self.unit_price = unit_price
        self.quantity_on_hand = quantity_on_hand

除了基本用法,以下快速帶過幾個 dataclass 的進階應用:

dataclass parameters

先附上 dataclass parameters 的 default values,再挑幾個比較常用的來介紹:

@dataclass(
    init=True,
    repr=True,
    eq=True,
    order=True,
    unsafe_hash=False,
    frozen=False,
    kw_only=False,
    slots=False,
    weakref_slot=False,
)
class Demo:
    number: int
    text: str
  • **init**: 要不要生成 __init__() method
  • **repr**: 要不要生成 __repr__() method

預設的 repr 效果如下:

print(Demo(number=1, text="Python"))
# Demo(number=1, text='Python')
  • **eq**: 要不要生成 __eq__() method (比較邏輯為將 fields value 打包成 tuple 後,跟對象 class 作比較)
  • **order**: 要不要生成下列 comparison methods,比較方法同 eq ( __lt__(), __le__(), __gt__(), __ge__() )
  • **frozen: 是否要讓 instance 有 frozen 效果 (即 ”類似**” immutable 的)。 底層機制是透過 __setattr__()__delattr__() 被呼叫時拋出 FrozenInstanceError 來阻止 instance 被變動,但有微小的 perfomance penalty,具體說明可看這裡
  • **kw_only: 是否在建構 instance 只允許透過 keyword argument 的方式傳入。我個人是偏好這個選項的,所謂 **Explicit is better than implicit.
  • **slots**: 是否要建構 __slots__ attributes 來提升 space efficiency。 這個 namedtuple 有介紹到,不贅述,底下附個證明。
from dataclasses import dataclass
from pympler.asizeof import asizeof

@dataclass
class DemoOne:
    number: int
    text: str

@dataclass(slots=True)
class DemoTwo:
    number: int
    text: str

print(asizeof(DemoOne(number=1, text="2")))  # 520
print(asizeof(DemoTwo(number=1, text="2")))  # 128

default field value

像普通的 class field definition 一樣,可以在 field type annotation 旁邊加上 default values:

@dataclass
class Demo:
    number: int = 0

print(Demo().number)  # 0

dataclasses.**field**

dataclass 內部 Field 物件的 constructor function,可以對 attributes 做更細節的設定:

from dataclasses import dataclass, field

@dataclass
class C:
    my_string: str = field(default="default")
    my_list: list[int] = field(default_factory=list)

# list all field function parameters and their default values below:
field(
  default=MISSING,
  default_factory=MISSING,
  init=True,
  repr=True,
  hash=None,
  compare=True,
  metadata=None,
  kw_only=MISSING,
)
  • ***init***: field 是否作為 dataclass init function 中的其中一個參數。 (可搭配下面介紹的 post-init 操作)
  • ***repr***: fielf 是否要顯示於 repr 字串中。 (eg. password 等機敏資料就可以設定為 False)
  • ***default*/ `default_factory`**:該 field 的預設值 / 預設值的 factory function
  • 兩者只能設定其一
  • default_factory 需為 zero-argument callable
  • ***compare*: field 是否要出現在 equality / comparison method 中。 (__eq__ / __gt__, etc)
  • ***kw_only***: field 是否只能以 kw 的方式傳入 constructor。

Post-init processing

dataclass 提供了一個 __post__init__ function,會在 __init__ function 結束後呼叫。__post__init__ 可以搭配以下兩種情境來應用:

  • field(init=False),在 postinit__ 中才做處理:
@dataclass
class C:
    a: float
    b: float
    c: float = field(init=False)
    # c variable won't be passed int __init__func

    def __post_init__(self):
        self.c = self.a + self.b
        # assign c in __post_init__

print(C(a=1, b=2))  # C(a=1, b=2, c=3)
  • 搭配 dataclass 特殊的 Type Annotation ***InitVar***,表示該 field 只會在 init 階段傳入、並不會成為 dataclass 的 attribute。 dataclass 會在 __post_init__ 中傳入 InitVar,方便開發者利用該參數:
@dataclass
class C:
    i: int
    j: int | None = None
    database: InitVar[DatabaseType | None] = None

    def __post_init__(self, database):
        if self.j is None and database is not None:
            self.j = database.lookup('j')

c = C(10, database=my_database)

Class variables

由於 dataclass 的 schema definition 方式取代了原本定義 class variable 的邏輯,如果要給 dataclass 定義 class variable 的話,需要使用 typing.ClassVar 來達成,讓 dataclass 在 init 階段忽略此欄位:

from dataclasses import dataclass
from typing import Any, ClassVar

@dataclass
class Person:
    name: str
    cnt: ClassVar[int] = 0

    def __post_init__(self):
        self.__class__.cnt += 1

p1 = Person(name="P1")  # no need to pass cnt parameter
p2 = Person(name="P2")
print(p1)          # Person(name='P1')
print(Person.cnt)  # 2

Conclusion, and more solutions

以上介紹的三個 Python 原生模組,可以讓開發者快速的搭建 data schema structure,增加可讀性的同時也減少 typo 的機率。

不過要強調的是,TypedDictnamedtupledataclass 都沒有驗證欄位 (validation) 的效果; 雖然在 TypedDict,dataclass 可以透過 type hint 對 field type 進行標註,但即使傳入與 type hint 不合的 data type,在執行時也不會出限任何錯誤。

如果想要有 data schema / field validation / serialization 的功能,可參考以下列舉的 third-party packages:


메타데이터
post_id
12dbc86f83ea
slug
how-python-structures-data-set-12dbc86f83ea
url
https://medium.com/@40243105s/how-python-structures-data-set-12dbc86f83ea
canonical_url
https://medium.com/@40243105s/how-python-structures-data-set-12dbc86f83ea
author_url
https://medium.com/@40243105s
status
ok
fetched_at
2026-07-19 05:45:37