Skip to content

Smart save

By default, pydynox tracks which fields changed and only sends those to DynamoDB. This saves WCU (Write Capacity Units) when updating large items.

Why this matters

DynamoDB charges for every byte you write. If you have a 10KB item and change one field, sending all 10KB wastes 9 WCU.

pydynox optimizes this. It only sends the changed field. One field = 1 WCU instead of 10.

This adds up fast:

  • 1 million updates/month on 4KB items
  • Without smart save: 4M WCU = $50/month
  • With smart save (200B average change): 200K WCU = $2.50/month
  • Savings: $47.50/month

How it works

When you load an item from DynamoDB, pydynox stores a snapshot of the original values. When you call save(), it compares current values with the original and only sends the changed fields using UpdateItem.

"""Basic smart save example - only changed fields are sent to DynamoDB."""

import asyncio

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


class User(Model):
    model_config = ModelConfig(table="users")
    pk = StringAttribute(partition_key=True)
    sk = StringAttribute(sort_key=True)
    name = StringAttribute()
    email = StringAttribute()
    bio = StringAttribute()


async def main():
    # Load a 4KB item with 20 fields
    user = await User.get(pk="USER#1", sk="PROFILE")
    if user:
        # Change one field
        user.name = "New Name"

        # Only sends 'name' to DynamoDB (not all 4KB)
        await user.save()


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

Cost savings

DynamoDB charges 1 WCU per 1KB written.

Item size Fields changed Without smart save With smart save Savings
1KB 1 field (50B) 1 WCU 1 WCU 0%
4KB 1 field (50B) 4 WCU 1 WCU 75%
10KB 1 field (50B) 10 WCU 1 WCU 90%
4KB 2KB of fields 4 WCU 2 WCU 50%

Measure WCU consumed

Run this example to see the WCU difference between smart save and full replace:

"""Compare WCU consumed: smart save vs full replace."""

import asyncio

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


class User(Model):
    model_config = ModelConfig(table="users")
    pk = StringAttribute(partition_key=True)
    sk = StringAttribute(sort_key=True)
    name = StringAttribute()
    email = StringAttribute()
    bio = StringAttribute()
    address = StringAttribute()
    phone = StringAttribute()
    company = StringAttribute()


async def main():
    # Create a large item (~2KB)
    user = User(
        pk="USER#wcu",
        sk="PROFILE",
        name="John Doe",
        email="john@example.com",
        bio="A" * 1000,  # 1KB of data
        address="123 Main St, City, Country",
        phone="+1-555-0123",
        company="Acme Corp",
    )
    await user.save()

    # Reload to enable change tracking
    user = await User.get(pk="USER#wcu", sk="PROFILE")
    if not user:
        return

    # Test 1: Smart save (only changed field)
    User.reset_metrics()
    user.name = "Jane Doe"
    await user.save()
    smart_metrics = User.get_total_metrics()

    # Test 2: Full replace (all fields)
    User.reset_metrics()
    user.name = "Bob Smith"
    await user.save(full_replace=True)
    full_metrics = User.get_total_metrics()

    # Results
    print("=== WCU Comparison ===")
    print(f"Smart save (UpdateItem): {smart_metrics.total_wcu} WCU")
    print(f"Full replace (PutItem):  {full_metrics.total_wcu} WCU")
    print(f"Savings: {full_metrics.total_wcu - smart_metrics.total_wcu} WCU")

    # Cleanup
    await user.delete()


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

Example output:

=== WCU Comparison ===
Smart save (UpdateItem): 1 WCU
Full replace (PutItem):  2 WCU
Savings: 1 WCU

Check if item changed

Use is_dirty and changed_fields to see what changed:

"""Check if item changed using is_dirty and changed_fields."""

import asyncio

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


class User(Model):
    model_config = ModelConfig(table="users")
    pk = StringAttribute(partition_key=True)
    sk = StringAttribute(sort_key=True)
    name = StringAttribute()
    email = StringAttribute()


async def main():
    user = await User.get(pk="USER#1", sk="PROFILE")
    if user:
        print(user.is_dirty)  # False

        user.name = "New Name"
        print(user.is_dirty)  # True
        print(user.changed_fields)  # ["name"]

        user.email = "new@example.com"
        print(user.changed_fields)  # ["name", "email"]


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

Force full replace

If you need to replace the entire item (using PutItem instead of UpdateItem), use full_replace=True:

"""Force full replace using PutItem instead of UpdateItem."""

import asyncio

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


class User(Model):
    model_config = ModelConfig(table="users")
    pk = StringAttribute(partition_key=True)
    sk = StringAttribute(sort_key=True)
    name = StringAttribute()


async def main():
    user = await User.get(pk="USER#1", sk="PROFILE")
    if user:
        user.name = "New Name"

        # Forces PutItem with all fields
        await user.save(full_replace=True)


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

Use this when:

  • You want to remove fields that are not in the model
  • You need PutItem behavior for some reason

New items

New items (not loaded from DynamoDB) always use PutItem:

"""New items always use PutItem, then smart save kicks in."""

import asyncio

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


class User(Model):
    model_config = ModelConfig(table="users")
    pk = StringAttribute(partition_key=True)
    sk = StringAttribute(sort_key=True)
    name = StringAttribute()


async def main():
    # New item - uses PutItem
    user = User(pk="USER#new", sk="PROFILE", name="John")
    await user.save()  # PutItem

    # After save, tracking is enabled
    user.name = "Jane"
    await user.save()  # UpdateItem (smart save)

    # Cleanup
    await user.delete()


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

With conditions

Smart save works with conditions:

"""Smart save works with conditions."""

import asyncio

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


class User(Model):
    model_config = ModelConfig(table="users")
    pk = StringAttribute(partition_key=True)
    sk = StringAttribute(sort_key=True)
    status = StringAttribute()


async def main():
    user = await User.get(pk="USER#1", sk="PROFILE")
    if user:
        user.status = "active"

        # UpdateItem with condition
        try:
            await user.save(condition=User.status == "pending")
        except Exception:
            # Condition failed - status was not "pending"
            pass


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

With optimistic locking

Smart save works with version attributes:

"""Smart save works with optimistic locking (version attribute)."""

import asyncio

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


class User(Model):
    model_config = ModelConfig(table="users")
    pk = StringAttribute(partition_key=True)
    sk = StringAttribute(sort_key=True)
    name = StringAttribute()
    version = VersionAttribute()


async def main():
    user = await User.get(pk="USER#1", sk="PROFILE")
    if user:
        user.name = "New Name"

        # UpdateItem with version check
        await user.save()


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