DynamoDB & Single-Table Models
Single-table models provide an alternative approach to modeling and persisting application data via DynamoDB, designed for projects that cannot rely on a dedicated relational database (such as Atlas’ default SQLAlchemy + PostgreSQL setup).
Single-table models can also be used alongside relational persistence, allowing projects to combine relational and non-relational storage strategies within the same Atlas application when different parts of the domain have different requirements.
Below are the details on how to create and persist single-table models, also known as dynamodels.
Initializing the dynamodels package
Before creating single-table models, create the src/dynamodels directory. This will be the Python package where all the API's single-table models and their auxiliary classes will be stored. Also, create a __init__.py file inside it for convenient importing.
Example:
from .card import Card
from .decree import Decree
from .player import Player
from .match import Match
from .match_code_lk import MatchCodeLK
from .match_player_id_lk import MatchPlayerIdLK
__all__ = [
"Card",
"Decree",
"Player",
"Match",
"MatchCodeLK"
"MatchPlayerIdLK"
]
Creating a Single-Table Model
To create a single-table model, follow the steps below:
- Create a file inside
src/dynamodelswith the name of the model in snake_case; - Declare the model's class by extending SingleTableEntity and naming it with the same name as the file, but in PascalCase;
- Add an entry for your model in
src/dynamodels/__init__.py; - Declare your fields as you would do in any normal Python class — you can also use Pydantic's
Fieldfeature;
Done.
Example:
import random
from pydantic import Field
from typing import List
from atlas.dynamodb import SingleTableEntity
from dynamodels import Player, Decree
from enums import MatchStatusEnum
class Match(SingleTableEntity):
code: str = Field(default_factory=lambda: "".join(random.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(6)))
players: List[Player] = Field(default_factory=list)
pending_players: List[Player] = Field(default_factory=list)
chain: List[Decree] = Field(default_factory=list)
status: MatchStatusEnum = MatchStatusEnum.LOBBY
Important Notes: Non-relational Modeling
Note that not every model inside dynamodels needs to extend SingleTableEntity, but only those that abstract domain entities that need to be identifiable as individual structures.
In non-relational databases such as DynamoDB, data is not organized around normalized tables and joins, but around access patterns and item shapes. For this reason, not every class inside the dynamodels package should extend SingleTableEntity.
Only classes that represent individually addressable domain entities — that is, entities that must be stored, retrieved, updated, or deleted as standalone items — should inherit from SingleTableEntity.
Supporting structures, embedded objects, value types, and aggregates that exist only as part of another entity should remain as plain Python or Pydantic models. These objects are persisted inline within a parent entity, rather than as independent DynamoDB items.
Example — Player, that only exists inside a Match:
from pydantic import BaseModel, Field
from typing import List
from uuid import uuid4
from dynamodels import Card
from enums import SocialClassEnum
class Player(BaseModel): # Note that this model does not extend SingleTableEntity
id: str = Field(default_factory=lambda: str(uuid4()))
auth_token: str
name: str
host: bool = False
ready: bool = False
awards: int = 0
cards: List[Card] = Field(default_factory=list)
social_class: SocialClassEnum = SocialClassEnum.MIDDLE_CLASS
This distinction is fundamental to Single-Table Design:
- Entities map to top-level DynamoDB items.
- Value objects and aggregates are stored as nested attributes.
- Data duplication and denormalization are expected and intentional.
- Modeling your domain this way ensures predictable access patterns, efficient queries, and avoids forcing relational concepts (such as normalization and joins) into a non-relational storage system.
Creating Lookup Key Objects
When there's a need to retrieve a single-table model using data from one of its nested or aggregated objects, you can create a Lookup Key object.
A Lookup Key object is a lightweight model that extends SingleTableEntity and exists solely to link an alternate access pattern (such as a nested field) to the primary single-table entity.
This approach follows DynamoDB’s Single-Table Design principles, where different access patterns are modeled explicitly instead of relying on joins or secondary queries.
Example — MatchPlayerIdLK, that enables tracking a Match through one of its related Player nested objects' player_id field:
from atlas.dynamodb import SingleTableEntity
class MatchPlayerIdLK(SingleTableEntity):
match_pk: str
Example — Creating the MatchPlayerIdLK link when Match is created:
@service_method
def create_and_enqueue(self, player_name: str) -> Dict[str, Any]:
player = Player(name=player_name, host=True, auth_token="dummy")
player.auth_token = self._sign_token_for(player.id)
match = Match(pending_players=[player])
single_table_service.create(match)
single_table_service.create(MatchCodeLK(pk=f"{MatchCodeLK.__name__}#{match.code}", sk="LK", match_pk=match.pk))
single_table_service.create(MatchPlayerIdLK(pk=f"{MatchPlayerIdLK.__name__}#{player.id}", sk="LK", match_pk=match.pk))
return {"token": player.auth_token, "match_code": match.code}
Example — Getting Match via player_id:
@service_method
def get_by_player_id(self, player_id: str) -> Match:
try:
match_player_id_lk = single_table_service.get(MatchPlayerIdLK, f"{MatchPlayerIdLK.__name__}#{player_id}", "LK")
return single_table_service.get(Match, match_player_id_lk.match_pk)
except NotFoundError as e:
raise NotFoundError(Messages.get("MATCH_NOT_FOUND_ERROR"))
Persisting and Reading Data
To persist and query single-table data, you can use Atlas' DynamoDB integration service SingleTableService. All data is stored at the domain DynamoDB table.