7 ways to implement DTOs in Python and what to keep in mind

izabelakowal

Izabela Kowal

Posted on April 19, 2021

7 ways to implement DTOs in Python and what to keep in mind

Data Transfer Objects are simply data structures typically used to pass data between application layers or between services.

The simplest form of DTO in Python can be just a dictionary:

itemdto = {
    "name": "Potion",
    "location": Location("Various"),
    "description": "Recover 20 HP",
}
Enter fullscreen mode Exit fullscreen mode

Issues (among others): mutable, lack of typing, lack of specificity.

We will use here the Location class, which might be a model class managed by an ORM in the real-life scenario:

class Location:
    def __init__(self, name: str) -> None:
        self.name = name

    def __repr__(self) -> str:
        return f"Location(name={self.name})"

    def __eq__(self, other: Location) -> bool:
        return self.name == other.name
Enter fullscreen mode Exit fullscreen mode

If we want to set the attributes of a DTO more precisely, we can define a separate class:

class ItemDto:
    def __init__(self, name: str, location: Location, description: str = "") -> None:
        self.name = name
        self.location = location
        self.description = description
Enter fullscreen mode Exit fullscreen mode

Alternatively, we can use kwargs and a .get() method for optional parameters:

class ItemDto:
    def __init__(self, **kwargs) -> None:
        self.name = kwargs["name"]
        self.location = kwargs["location"]
        self.description = kwargs.get("description", "")

itemdto = ItemDto(
    name="Super Potion",
    location=Location("Various"),
    description="Recover 70 HP"
)
Enter fullscreen mode Exit fullscreen mode

Well defined DTOs can give us more benefits, such as making it easier to perform serialization or validation. Here are a few examples of using different features of Python standard library and 3rd party packages to create better DTOs.

stdlib solutions

dataclasses

  • added to Python 3.7 (and later backported to Python 3.6)

  • created using a @dataclass decorator

  • by default add automatically generated dunder methods __init__, __repr__ and __eq__

  • __init__ method takes all fields as method parameters and sets their values to instance attributes with the same names:

from dataclasses import dataclass

@dataclass
class ItemDto:
    name: str
    location: Location
    description: str = ""


# support both positional and keyword args
itemdto = ItemDto(
    name="Old Rod",
    location=Location("Vermillion City"),
    description="Fish for low-level Pokemon",
)
Enter fullscreen mode Exit fullscreen mode
  • generated __repr__ method returns a string containing class name, field names and field representation
>>> print(itemdto)
ItemDto(name='Old Rod', location=Location(name=Vermillion City), description='Fish for low-level Pokemon')
Enter fullscreen mode Exit fullscreen mode
  • generated __eq__ method compares the class tuples containing field values of the current and the other instance
itemdto2 = ItemDto(
    name="Old Rod",
    location=Location("Vermillion City"),
    description="Fish for low-level Pokemon",
)

>>> itemdto == itemdto2
True
Enter fullscreen mode Exit fullscreen mode
  • __eq__ method works the same as if we would explicitly declare it this way:
def __eq__(self, other):
    if other.__class__ is self.__class__:
        return (self.name, self.location, self.description) == (other.name, other.location, other.description)
    return NotImplemented
Enter fullscreen mode Exit fullscreen mode
  • it might be a good idea to make DTO instances immutable. It is possible by setting the argument frozen to True:
@dataclass(frozen=True)
class ItemDto:
    name: str
    location: Location
    description: str = ""
Enter fullscreen mode Exit fullscreen mode
  • not iterable:
...: for field in itemdto:
...:     print(field)
...: 
TypeError: 'ItemDto' object is not iterable
Enter fullscreen mode Exit fullscreen mode

More on dataclasses:

NamedTuples

  • NamedTuple is a subclass of regular tuple
  • introduced in Python 3.0 as a factory method in the collections module:
from collections import namedtuple

ItemDto = namedtuple("ItemDto", ["name", "location", "description"])
Enter fullscreen mode Exit fullscreen mode
  • added to Python 3.5 as a typed version in typed module and later enhanced with variable annotations syntax in Python 3.6:
from typing import NamedTuple

class ItemDto(NamedTuple):
    name: str
    location: Location
    description: str = ""


# support both positional and keyword args
itemdto = ItemDto(
    "X Speed", "Temporarily raise Speed in battle", Location("Celadon Dept. Store")
)

>>> print(itemdto)
ItemDto(name='X Speed', location='Temporarily raise Speed in battle', description='Celadon Dept. Store')
Enter fullscreen mode Exit fullscreen mode
  • immutable
  • __repr__ and __eq__ handled
  • iterable
...: for field in itemdto:
...:     print(field)
...: 
X Speed
Temporarily raise Speed in battle
Location(name=Celadon Dept. Store)
Enter fullscreen mode Exit fullscreen mode
  • support default values, although these must be defined after any fields without default values.

More on NamedTuples:

TypedDicts

  • available since Python 3.8:
from typing import TypedDict

class ItemDto(TypedDict):
    name: str
    location: Location
    description: str


itemdto = ItemDto(
    name="Escape Rope,",
    location=Location("Various"),
    description="Teleport to last visited Pokemon Center",
)

>>> print(itemdto)
{'name': 'Escape Rope,', 'location': Location(name=Various), 'description': 'Teleport to last visited Pokemon Center'}
Enter fullscreen mode Exit fullscreen mode
  • mutable
  • __repr__ and __eq__ handled
  • iterable in dict kind of way
  • don't support default values
  • can provide typing for existing dictionaries
  • since those are still dictionaries, after all, they can be directly serialized to JSON data structures (although in this example, we should provide a custom encoder for the Location class).

More on TypedDicts:

3rd party packages

attrs

  • a pytest dependency, so there's a chance you might already have it in your project
  • similar to the dataclasses, in fact, the attrs library was the basis for designing the dataclasses:
import attr

@attr.s(frozen=True)
class ItemDto:
    name: str = attr.ib()
    location: Location = attr.ib()
    description: str = attr.ib(default="")

# also, the dataclasses syntax!
@attr.dataclass(frozen=True)
class ItemDto:
    name: str
    location: Location
    description: str = ""
Enter fullscreen mode Exit fullscreen mode
  • attrs provide some extra functionality on top of those that the dataclasses offer, like runtime validation and memory optimization (slotted classes).

Here's an example of runtime validation:

@attr.s(frozen=True)
class PokemonDto:
    name: str = attr.ib()
    type: str = attr.ib(
        validator=attr.validators.in_(
            [
                "Fire",
                "Water",
                "Electric",
                "Poison",  # ...
            ]
        )
    )

>>> PokemonDto("Charmander", "Fire")
PokemonDto(name='Charmander', type='Fire')

>>> PokemonDto("Charmander", "Gyarados")
ValueError: 'type' must be in ['Fire', 'Water', 'Electric', 'Poison'] (got 'Gyarados')
Enter fullscreen mode Exit fullscreen mode

Whether to choose attrs or dataclasses - it ultimately depends on your specific use case and if you are able to use 3rd party packages in your project.

More on attrs:

pydantic

  • FastAPI uses pydantic for schema definition and data validation

  • pydantic enforces type hints at runtime

  • the recommended way for creating pydantic models is to subclass pydantic.BaseModel, therefore all models inherit some methods:

from pydantic import BaseModel

class PokemonDto(BaseModel):
    name: str
    type: str

    class Config:
        allow_mutation = False


# enforced keyword arguments in case of BaseModel subclass
pokemondto = PokemonDto(name="Charizard", type="Fire")
Enter fullscreen mode Exit fullscreen mode
  • like attrs, pydantic also supports vanilla Python dataclasses:
import pydantic

@pydantic.dataclasses.dataclass(frozen=True)
class PokemonDto:
    name: str
    type: str

# in this case positional args are allowed
PokemonDto("Charizard", "Fire")
Enter fullscreen mode Exit fullscreen mode
  • enables (recursive) data validation:
from enum import Enum

from pydantic import BaseModel

class TypeEnum(str, Enum):
    fire = "Fire"
    water = "Water"
    electric = "Electric"
    poison = "Poison"
    # ...


class PokemonDto(pydantic.BaseModel):
    name: str
    type: TypeEnum

    class Config:
        allow_mutation = False


>>> PokemonDto(name="Charizard", type="Fire")
PokemonDto(name='Charizard', type=<TypeEnum.fire: 'Fire'>)

>>> PokemonDto(name="Charizard", type="Charmeleon")
ValidationError: 1 validation error for PokemonDto
type
  value is not a valid enumeration member; permitted: 'Fire', 'Water', 'Electric', 'Poison' (type=type_error.enum; enum_values=[<TypeEnum.fire: 'Fire'>, <TypeEnum.water: 'Water'>, <TypeEnum.electric: 'Electric'>, <TypeEnum.poison: 'Poison'>])
Enter fullscreen mode Exit fullscreen mode
  • enables JSON (de)serialization:
>>> PokemonDto(name="Charizard", type="Fire").json()
'{"name": "Charizard", "type": "Fire"}'
Enter fullscreen mode Exit fullscreen mode

More on pydantic:

Summary

Your choice on how to implement the DTOs depend on multiple circumstances - whether you need, among others:

  • immutability
  • default values support
  • iterability
  • serialization
  • runtime type checking
  • performance optimization
  • other, more advanced configurability.

Post initially inspired by this Reddit thread.

💖 💪 🙅 🚩
izabelakowal
Izabela Kowal

Posted on April 19, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related