Skip to main content

Single-table design for DynamoDB in Python, with typed entities on top of Pydantic v2.

Project description

pydynantic

PyPI version Python versions CI codecov License: MIT Typed

Single-table design for DynamoDB in Python, with typed entities on top of Pydantic v2.

pydynantic is the Pythonic answer to JavaScript's ElectroDB and DynamoDB-Toolbox: model many entities in a single DynamoDB table, compose keys from attributes, declare named & typed access patterns, and never hand-write an UpdateExpression again.

  • 🧩 Declarative entities built on Pydantic v2 (validation, defaults, nested models).
  • 🔑 Automatic key composition from templates like "ORG#{org_id}" — primary keys and GSIs.
  • 🔎 Typed, named access patterns instead of raw expressions.
  • 🔁 Transparent marshalling for str, int, float, Decimal, bool, bytes, datetime, date, UUID, Enum, list, dict, set, and nested Pydantic models.
  • 🧱 Single-table primitives: collections, batch get/write, transactions, optimistic locking, cursor pagination.
  • Type-safe: ships py.typed, passes mypy --strict.

The library never opens its own connections — you inject a boto3 client, which keeps credentials, region, endpoint, and testing fully in your control.


Installation

pip install pydynantic

Requires Python 3.10+ and boto3 / pydantic>=2.

Quick start

import boto3
from datetime import datetime, timezone
from pydynantic import Table, Entity, Field, key, F

# 1. Describe the physical table (one instance per real table).
table = Table(
    name="app-prod",
    pk="PK",
    sk="SK",
    indexes={"GSI1": {"pk": "GSI1PK", "sk": "GSI1SK"}},
    client=boto3.client("dynamodb"),   # injected; testable
)

# 2. Declare entities. Attributes use plain Pydantic syntax; keys are templates.
class User(Entity, table=table, name="user"):
    user_id: str
    org_id: str
    email: str
    name: str
    status: str = "active"
    login_count: int = 0
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

    class Meta:
        primary = key(pk="ORG#{org_id}", sk="USER#{user_id}")
        by_email = key(index="GSI1", pk="EMAIL#{email}", sk="USER#{user_id}")

# 3. CRUD — no expressions by hand.
User.put(User(user_id="u1", org_id="acme", email="a@x.com", name="Ana"))

user = User.get(org_id="acme", user_id="u1")          # -> User | None

User.update(
    org_id="acme", user_id="u1",
    set={"name": "Ana B.", "status": "inactive"},
    add={"login_count": 1},
)

User.delete(org_id="acme", user_id="u1")

Queries & access patterns

# Partition + begins_with on the sort key:
users = (User.query.primary(org_id="acme")
                   .begins_with("USER#")
                   .limit(50)
                   .all())                              # -> list[User]

# Access by GSI:
user = User.query.by_email(email="a@x.com").one_or_none()  # -> User | None

# Sort-key operators: .eq .begins_with .between .gt .gte .lt .lte
# Terminals: .all() .one() .one_or_none() .first() .iter() .page(cursor=...) .count()

# Count server-side (no items materialised):
active = User.query.primary(org_id="acme").filter(F("status") == "active").count()

# Projection — fetch only some attributes (omitted ones fall back to defaults):
User.query.primary(org_id="acme").attributes(["user_id", "name"]).all()
User.get(org_id="acme", user_id="u1", attributes=["user_id", "name"])

Scan

# Full-table scan, automatically restricted to this entity's items:
all_users = User.scan().all()
User.scan().filter(F("status") == "active").count()
# Same builder surface as query: .filter .limit .attributes .all .first .iter .page .count

Filters & conditions

User.query.primary(org_id="acme").filter(
    (F("status") == "active") & (F("login_count") > 0)
).all()

# Create-only put:
from pydynantic import attr_not_exists
User.put(user, condition=attr_not_exists("PK"))

Operators: == != < <= > >=, .between(), .begins_with(), .contains(), .exists(), .not_exists(), .is_in([...]), combined with &, |, ~. ExpressionAttributeNames/Values are managed for you with no placeholder collisions.

Pagination

page = User.query.primary(org_id="acme").limit(25).page()
page.items        # list[User]
page.cursor       # opaque, JSON/HTTP-safe token (None when exhausted)

next_page = User.query.primary(org_id="acme").limit(25).page(cursor=page.cursor)

Batch

# Chunks of 25 (write) / 100 (get) and UnprocessedItems/Keys retried with backoff.
users = User.batch_get([("acme", "u1"), ("acme", "u2")])
User.batch_write(puts=[...], deletes=[("acme", "u3")])

Transactions

from pydynantic import transaction

with transaction(table) as tx:
    tx.put(Order(...))
    tx.update(User, key=("acme", "u1"), add={"orders": 1})
    tx.condition_check(Account, key=("acme",), condition=F("balance") >= 100)
# Commits on a clean exit; a failed condition cancels everything atomically.

Collections (several entities, one partition)

from pydynantic import Collection

class OrgData(Collection):
    members = [User, Membership, Invoice]   # share PK = ORG#{org_id}

result = OrgData.query(org_id="acme").all()
result.users        # list[User]
result.invoices     # list[Invoice]

Items are discriminated by a reserved __entity__ attribute written on every item.

Optimistic locking

from pydynantic import version_attr

class Order(Entity, table=table, name="order"):
    order_id: str
    customer_id: str
    total: float = 0.0
    version: int = version_attr()        # marks the version field

    class Meta:
        primary = key(pk="CUSTOMER#{customer_id}", sk="ORDER#{order_id}")

Each versioned put/update increments version and guards on the previous value; a lost race raises OptimisticLockError.

Error handling

All errors derive from PydynanticError (raw ClientError is never leaked):

PydynanticError
├── ItemNotFoundError
├── ConditionCheckFailedError
│   └── OptimisticLockError
├── ValidationError
├── TransactionCanceledError    (.reasons per action)
├── KeyTemplateError
└── MultipleResultsError

Marshalling notes

  • Numbers are stored as Decimal to avoid floating-point drift.
  • datetime/date are stored as ISO-8601 strings.
  • None values and empty sets are omitted from items (DynamoDB cannot store them).

Development

pip install -e ".[dev]"
pytest                 # unit tests on moto (in-memory DynamoDB)
pytest --cov=pydynantic --cov-report=term-missing
mypy                   # strict
ruff check .

Scope

In scope (v1): single-table modelling, keys, CRUD, queries, filters/conditions, pagination, batch, transactions, collections, optimistic locking. Synchronous only.

Out of scope: table/infra provisioning (use CDK/Terraform), data migrations, multi-table joins, async (planned for v2 on aioboto3).

License

MIT © Robert Ruben

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pydynantic-1.0.0.tar.gz (85.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pydynantic-1.0.0-py3-none-any.whl (35.6 kB view details)

Uploaded Python 3

File details

Details for the file pydynantic-1.0.0.tar.gz.

File metadata

  • Download URL: pydynantic-1.0.0.tar.gz
  • Upload date:
  • Size: 85.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pydynantic-1.0.0.tar.gz
Algorithm Hash digest
SHA256 bd780c85def989ccd993bac9e75d0e1785e131c5b5c9f8879b34ea54cc91ae36
MD5 0764c48fe4d6e834ac9b682113c2d66f
BLAKE2b-256 6f372c5876166b217aad72b794825b3a677ddaf6b5a0c4836514faca444bc8d9

See more details on using hashes here.

Provenance

The following attestation bundles were made for pydynantic-1.0.0.tar.gz:

Publisher: release.yml on robertruben98/pydynantic

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pydynantic-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: pydynantic-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 35.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pydynantic-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0c27498cb2c26778417e2af47d12b9dfcbf72dc3a4c8957f4f844b0fe1b9b40f
MD5 65e034901ded8ff5a6839c535c4d357c
BLAKE2b-256 d96aa8ae4aaddcf0fc134ed1e5e0f4a3c4f232d691c13bc230cc583e13d3a8ae

See more details on using hashes here.

Provenance

The following attestation bundles were made for pydynantic-1.0.0-py3-none-any.whl:

Publisher: release.yml on robertruben98/pydynantic

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page