Skip to content

Model inheritance

Share attributes across models and let pydynox figure out which class to use when loading items.

Key features

  • Base classes with shared attributes (timestamps, audit fields, etc.)
  • Discriminator field for single-table design
  • Automatic type resolution on get/query/scan
  • Deep inheritance (Dog → Animal → Model)
  • Zero overhead - just Python classes

Why use inheritance?

Problem 1: Repeated attributes

Every model needs created_at, updated_at, created_by. Copy-paste is error-prone:

# Without inheritance - copy-paste everywhere
class User(Model):
    pk = StringAttribute(partition_key=True)
    name = StringAttribute()
    created_at = DatetimeAttribute()  # repeated
    updated_at = DatetimeAttribute()  # repeated

class Product(Model):
    pk = StringAttribute(partition_key=True)
    title = StringAttribute()
    created_at = DatetimeAttribute()  # repeated again
    updated_at = DatetimeAttribute()  # repeated again

Problem 2: Single-table design

You store Dogs and Cats in the same table. When you load an item, you need to know which class to use:

# Without discriminator - manual type checking
item = await client.get_item("animals", {"pk": "ANIMAL#1"})
if item["type"] == "dog":
    dog = Dog(**item)
elif item["type"] == "cat":
    cat = Cat(**item)

pydynox solves both problems with Python inheritance.

Base classes

Create a base class with common attributes. Any model that inherits from it gets those attributes automatically:

import asyncio
from datetime import datetime, timezone

from pydynox import Model, ModelConfig
from pydynox.attributes import DatetimeAttribute, StringAttribute


# Base class with shared attributes
class TimestampBase:
    created_at = DatetimeAttribute()
    updated_at = DatetimeAttribute()


# Models inherit from both Model and the base class
class User(Model, TimestampBase):
    model_config = ModelConfig(table="users")
    pk = StringAttribute(partition_key=True)
    name = StringAttribute()


class Product(Model, TimestampBase):
    model_config = ModelConfig(table="products")
    pk = StringAttribute(partition_key=True)
    title = StringAttribute()


async def main():
    now = datetime.now(timezone.utc)

    # User has created_at and updated_at from TimestampBase
    user = User(pk="USER#1", name="John", created_at=now, updated_at=now)
    await user.save()

    loaded = await User.get(pk="USER#1")
    if loaded:
        assert loaded.created_at == now
        assert loaded.name == "John"


if __name__ == "__main__":
    asyncio.run(main())

The base class is just a regular Python class. No need to inherit from Model.

Common base classes

Base class Attributes Use case
TimestampBase created_at, updated_at Track when items were created/modified
AuditBase created_by, updated_by Track who created/modified items
SoftDeleteBase deleted_at, is_deleted Soft delete pattern
VersionBase version Optimistic locking

You can combine multiple base classes:

class User(Model, TimestampBase, AuditBase):
    model_config = ModelConfig(table="users")
    pk = StringAttribute(partition_key=True)
    name = StringAttribute()

Discriminator for single-table design

When you store different item types in the same table, add a discriminator field. pydynox uses it to return the correct class.

import asyncio

from pydynox import Model, ModelConfig
from pydynox.attributes import BooleanAttribute, StringAttribute


# Parent model with discriminator field
class Animal(Model):
    model_config = ModelConfig(table="animals")
    pk = StringAttribute(partition_key=True)
    name = StringAttribute()
    _type = StringAttribute(discriminator=True)  # Marks this as discriminator


# Subclasses - each gets its own attributes
class Dog(Animal):
    model_config = ModelConfig(table="animals")
    breed = StringAttribute()


class Cat(Animal):
    model_config = ModelConfig(table="animals")
    indoor = BooleanAttribute()


async def main():
    # Save a dog - _type is set automatically to "Dog"
    dog = Dog(pk="ANIMAL#1", name="Rex", breed="Labrador")
    await dog.save()

    # Save a cat - _type is set automatically to "Cat"
    cat = Cat(pk="ANIMAL#2", name="Whiskers", indoor=True)
    await cat.save()

    # Get from Animal - returns the correct subclass
    loaded = await Animal.get(pk="ANIMAL#1")
    if loaded:
        assert isinstance(loaded, Dog)
        assert loaded.breed == "Labrador"

    loaded = await Animal.get(pk="ANIMAL#2")
    if loaded:
        assert isinstance(loaded, Cat)
        assert loaded.indoor is True


if __name__ == "__main__":
    asyncio.run(main())

How it works

  1. Add discriminator=True to a StringAttribute in the parent model
  2. Create subclasses that inherit from the parent
  3. When you save, pydynox sets the discriminator to the class name ("Dog", "Cat")
  4. When you load, pydynox reads the discriminator and returns the correct subclass

Custom discriminator field

The discriminator field can have any name:

Field name Use case
_type Internal field, hidden from API responses
item_type Explicit, readable in DynamoDB console
entity_type Common in single-table designs
sk Part of sort key pattern (advanced)
import asyncio

from pydynox import Model, ModelConfig
from pydynox.attributes import StringAttribute


# Using item_type instead of _type
class Entity(Model):
    model_config = ModelConfig(table="entities")
    pk = StringAttribute(partition_key=True)
    item_type = StringAttribute(discriminator=True)


class Person(Entity):
    model_config = ModelConfig(table="entities")
    name = StringAttribute()


class Company(Entity):
    model_config = ModelConfig(table="entities")
    company_name = StringAttribute()


async def main():
    person = Person(pk="ENTITY#1", name="John")
    await person.save()

    # item_type is "Person"
    loaded = await Entity.get(pk="ENTITY#1")
    if loaded:
        assert loaded.item_type == "Person"
        assert isinstance(loaded, Person)


if __name__ == "__main__":
    asyncio.run(main())

Deep inheritance

Inheritance works at any depth. A GoldenRetriever can extend Dog which extends Animal:

import asyncio

from pydynox import Model, ModelConfig
from pydynox.attributes import BooleanAttribute, StringAttribute


class Animal(Model):
    model_config = ModelConfig(table="animals")
    pk = StringAttribute(partition_key=True)
    name = StringAttribute()
    _type = StringAttribute(discriminator=True)


class Dog(Animal):
    model_config = ModelConfig(table="animals")
    breed = StringAttribute()


# GoldenRetriever extends Dog, which extends Animal
class GoldenRetriever(Dog):
    model_config = ModelConfig(table="animals")
    is_service_dog = BooleanAttribute()


async def main():
    # Save a GoldenRetriever
    buddy = GoldenRetriever(
        pk="ANIMAL#1",
        name="Buddy",
        breed="Golden Retriever",
        is_service_dog=True,
    )
    await buddy.save()

    # Get from Animal - returns GoldenRetriever
    loaded = await Animal.get(pk="ANIMAL#1")
    if loaded:
        assert isinstance(loaded, GoldenRetriever)
        assert loaded.is_service_dog is True
        assert loaded.breed == "Golden Retriever"
        assert loaded._type == "GoldenRetriever"


if __name__ == "__main__":
    asyncio.run(main())

When you call Animal.get(), pydynox returns the most specific class (GoldenRetriever, not Dog or Animal).

Query and scan

When you query or scan from the parent class, pydynox returns the correct subclass for each item:

import asyncio

from pydynox import Model, ModelConfig
from pydynox.attributes import BooleanAttribute, StringAttribute


class Animal(Model):
    model_config = ModelConfig(table="animals")
    pk = StringAttribute(partition_key=True)
    name = StringAttribute()
    _type = StringAttribute(discriminator=True)


class Dog(Animal):
    model_config = ModelConfig(table="animals")
    breed = StringAttribute()


class Cat(Animal):
    model_config = ModelConfig(table="animals")
    indoor = BooleanAttribute()


async def main():
    # Save different animals
    await Dog(pk="ANIMAL#1", name="Rex", breed="Labrador").save()
    await Cat(pk="ANIMAL#2", name="Whiskers", indoor=True).save()
    await Dog(pk="ANIMAL#3", name="Max", breed="Poodle").save()

    # Scan from Animal - returns correct subclasses
    results = Animal.scan()
    items = [item async for item in results]

    dogs = [a for a in items if isinstance(a, Dog)]
    cats = [a for a in items if isinstance(a, Cat)]

    assert len(dogs) == 2
    assert len(cats) == 1

    # Each item has the correct type
    for item in items:
        if isinstance(item, Dog):
            assert hasattr(item, "breed")
        elif isinstance(item, Cat):
            assert hasattr(item, "indoor")


if __name__ == "__main__":
    asyncio.run(main())

This is powerful for single-table design. One scan returns a mix of Dogs and Cats, each with the correct type.

Combining base classes and discriminator

You can use both features together. This is common in real applications:

import asyncio
from datetime import datetime, timezone

from pydynox import Model, ModelConfig
from pydynox.attributes import BooleanAttribute, DatetimeAttribute, StringAttribute


# Base class with shared attributes
class AuditBase:
    created_at = DatetimeAttribute()
    created_by = StringAttribute()


# Parent model with discriminator
class Animal(Model, AuditBase):
    model_config = ModelConfig(table="animals")
    pk = StringAttribute(partition_key=True)
    name = StringAttribute()
    _type = StringAttribute(discriminator=True)


class Dog(Animal):
    model_config = ModelConfig(table="animals")
    breed = StringAttribute()


class Cat(Animal):
    model_config = ModelConfig(table="animals")
    indoor = BooleanAttribute()


async def main():
    now = datetime.now(timezone.utc)

    # Dog has both base class attributes and discriminator
    dog = Dog(
        pk="ANIMAL#1",
        name="Rex",
        breed="Labrador",
        created_at=now,
        created_by="admin",
    )
    await dog.save()

    # Load from Animal - returns Dog with all attributes
    loaded = await Animal.get(pk="ANIMAL#1")
    if loaded:
        assert isinstance(loaded, Dog)
        assert loaded.breed == "Labrador"
        assert loaded.created_by == "admin"
        assert loaded._type == "Dog"


if __name__ == "__main__":
    asyncio.run(main())

Single-table design example

Here's a complete example of single-table design with inheritance:

from pydynox import Model, ModelConfig
from pydynox.attributes import StringAttribute, NumberAttribute, BooleanAttribute


# Base class for audit fields
class AuditBase:
    created_by = StringAttribute()


# Parent model - all entities in one table
class Entity(Model, AuditBase):
    model_config = ModelConfig(table="app")
    pk = StringAttribute(partition_key=True)
    sk = StringAttribute(sort_key=True)
    entity_type = StringAttribute(discriminator=True)


# User entity
class User(Entity):
    model_config = ModelConfig(table="app")
    name = StringAttribute()
    email = StringAttribute()


# Order entity
class Order(Entity):
    model_config = ModelConfig(table="app")
    total = NumberAttribute()
    status = StringAttribute()


# Product entity
class Product(Entity):
    model_config = ModelConfig(table="app")
    title = StringAttribute()
    price = NumberAttribute()
    in_stock = BooleanAttribute()


async def main():
    # Save different entities to the same table
    await User(pk="USER#1", sk="PROFILE", name="John", email="john@example.com", created_by="system").save()
    await Order(pk="USER#1", sk="ORDER#001", total=99.99, status="pending", created_by="john").save()
    await Product(pk="PRODUCT#1", sk="DETAILS", title="Widget", price=9.99, in_stock=True, created_by="admin").save()

    # Query returns correct types
    user = await Entity.get(pk="USER#1", sk="PROFILE")
    assert isinstance(user, User)
    assert user.email == "john@example.com"

    order = await Entity.get(pk="USER#1", sk="ORDER#001")
    assert isinstance(order, Order)
    assert order.total == 99.99

Performance

Inheritance has zero runtime overhead:

  • Attribute collection happens once at class definition (import time)
  • Discriminator lookup is a dict lookup (O(1))
  • No reflection or introspection at runtime

The only cost is a few extra bytes for the discriminator field in DynamoDB.