Runtime Guides

Package-source copy · Generated into the site so runtime docs are searchable and linkable.

Troubleshooting (Python)

This guide maps common problems to verified fixes for the Python SDK.

Error: NoCredentialsError when using DynamoDB Local

Cause: boto3 still requires credentials even for DynamoDB Local.

Solution: Provide dummy credentials:

client = boto3.client(
    "dynamodb",
    endpoint_url="http://localhost:8000",
    region_name="us-east-1",
    aws_access_key_id="dummy",
    aws_secret_access_key="dummy",
)

Error: encrypted fields without kms_key_arn

Cause: models with encrypted fields fail closed unless kms_key_arn is configured.

Solution: pass kms_key_arn and (in tests) inject a fake KMS client.

Confusion: Git tag vX.Y.Z-rc.N vs Python version X.Y.ZrcN

Cause: Python follows PEP 440 prerelease normalization.

Solution: use the wheel filename from the GitHub Release asset list (e.g., tabletheory_py-1.2.1rc1-...whl).

Error: ResourceNotFoundException or TableNotFoundError

Cause: the ModelDefinition table name does not exist at the configured boto3 endpoint/region, or the client points at AWS when you expected DynamoDB Local.

Fix: verify table_name, endpoint_url, and region_name, then create the table through infrastructure or your integration-test setup.

client = boto3.client(
    "dynamodb",
    endpoint_url="http://localhost:8000",
    region_name="us-east-1",
    aws_access_key_id="dummy",
    aws_secret_access_key="dummy",
)

Error: missing or empty key fields

Cause: put, get, update, or delete received an item/key without the dataclass fields marked roles=["pk"] and roles=["sk"]. DynamoDB key attributes cannot be empty strings.

Fix: use a small key helper and keep field names aligned with theorydb_field(name=...).

def profile_key(user_id: str) -> tuple[str, str]:
    return (f"USER#{user_id}", "PROFILE")

pk, sk = profile_key("42")
item = table.get(pk, sk)

Error: GSI query returns no items or rejects consistent reads

Cause: GSI reads require index_name=..., and DynamoDB rejects consistent_read=True for GSIs.

Fix: pass the index name and leave consistent_read at its default False.

page = table.query("EMAIL#a@example.com", index_name="gsi_email", limit=25)

Error: encrypted fields without kms_key_arn

Cause: models with encrypted fields fail closed unless kms_key_arn is configured.

Fix: pass kms_key_arn in production and inject FakeKmsClient in tests. Do not remove encrypted=True to make a local test pass.

Error: encrypted field used in a filter or condition

Cause: DynamoDB sees encrypted envelopes, not plaintext, so encrypted attributes cannot be queried or compared.

Fix: query by plaintext PK/SK/GSI attributes and keep secrets in non-key encrypted fields.

Error: transaction canceled (TransactionCanceledException)

Cause: a DynamoDB transaction is atomic. When any operation’s condition fails, nothing is committed and DynamoDB returns a TransactionCanceledException with one cancellation reason per operation, in order ("None" for the operations that did not fail). TableTheory classifies that exception by the FIRST genuine failure — the first reason whose code is neither missing nor "None":

DynamoDB reason code raised class
ConditionalCheckFailed ConditionFailedError
TransactionConflict TransactionConflictError
ThrottlingException, ProvisionedThroughputExceeded, RequestLimitExceeded, InternalServerError, ServiceUnavailable ThrottledError
anything else, no genuine failure, or an empty reason list TransactionCanceledError

TransactionConflictError and ThrottledError subclass TransactionCanceledError, so existing except TransactionCanceledError handlers keep matching. An unknown or missing code is never reported as a condition failure, and an ambiguous transport error is mapped through map_client_error rather than being reclassified.

Fix: catch the specific class and read the ordered reasons and the original error:

from tabletheory_py import ConditionFailedError, TransactionConflictError, ThrottledError

try:
    table.transact_write(actions)
except ConditionFailedError as err:
    # A conditional write lost; nothing was committed.
    for reason in err.reasons:  # index / code / message, position-preserving
        print(reason.index, reason.code, reason.message)
except TransactionConflictError:
    ...
except ThrottledError:
    ...
# The original botocore ClientError stays recoverable:
#   except ... as err: isinstance(err.__cause__, ClientError)

.reasons is a tuple of CancellationReason(index, code, message) in original order. .reason_codes is kept for backward compatibility and is position-preserving (a missing code becomes ""). The typed cancellation fields never carry item images: each reason holds only index, code and message, and str(err) and the exception message never include item data or attribute values.

The original botocore ClientError is retained as err.__cause__. When the request returned item images (for example with ReturnValuesOnConditionCheckFailure) its .response still holds them. A plain logging.exception or exc_info traceback prints only the exception messages and stays clean, but serializing or dumping the retained error — err.__cause__.response, a structured logger that renders exception attributes, or an error-tracking SDK — includes the item image. Log the typed fields instead:

except TransactionCanceledError as err:
    # Safe: reasons carry only index / code / message — never item images.
    logger.warning(
        "transaction canceled",
        extra={"reasons": [(r.index, r.code, r.message) for r in err.reasons]},
    )

Safe serialization. safe_dict() on the error is a typed-only projection, safe to log or serialize:

except TransactionCanceledError as err:
    logger.warning("transaction canceled", extra={"error": err.safe_dict()})
    # {"type": "TransactionCanceledError", "message": ..., "reason_codes": [...], "reasons": [...]}

It never includes __cause__, __context__ or any request/response payload.

To strip the item images from the retained cause itself, construct the table with redact_item_images=True:

table = Table(model, client=client, redact_item_images=True)

It is off by default, so the retained __cause__ is the same object it always was. With it enabled the cause becomes a ClientError copy whose CancellationReasons[].Item and top-level Item are removed, and the original error object is never mutated. Until you enable it, dumping the whole error with the default settings can still reach the item images; changing that default would change __cause__ for every consumer, so it belongs to a future major release rather than a change made here.

Caller-built clients. redact_item_images covers the client a Table uses. A component you hand a raw client to does not inherit the option: a LeaseManager keeps the client you pass it, so the errors it maps stay unredacted. Wrap that client with redacting_client, which is exported from tabletheory_py.aws_errors (not from the package root):

import boto3

from tabletheory_py import LeaseManager
from tabletheory_py.aws_errors import redacting_client

client = redacting_client(boto3.client("dynamodb"))  # redact=True by default
leases = LeaseManager(client=client, table_name="Leases")

redacting_client(client) returns a wrapper whose mapped ClientError carries no item images; redacting_client(client, redact=False) returns the client unchanged.

API reference note. The generated Python API reference lists each public class and its public methods from the package __all__. It does not render the Table constructor, so the redact_item_images option is not listed there — this page documents it. redacting_client is not a root export, so it is not listed either.

Compatibility note: Python previously raised ConditionFailedError whenever ANY reason was ConditionalCheckFailed, or when the message merely mentioned it. Classification now follows the FIRST genuine failure and never reads the message text, so a message-only mention no longer turns an unknown failure into a condition failure. TransactionCanceledError.reason_codes now keeps every position instead of dropping entries without a code.

Pagination surprise: query_all or scan_all loads too much data

Cause: query_all, scan_all, and scan_all_segments follow every cursor and materialize the full result set.

Fix: use query, query_iter, scan, or scan_iter for large reads.

for page in table.query_iter("TENANT#1", limit=100):
    for item in page.items:
        if done(item):
            break
    break

Testing surprise: timestamp assertions are flaky

Cause: lifecycle fields are populated from the runtime clock.

Fix: inject now and strict fakes in unit tests.

from datetime import datetime, timezone

from tabletheory_py.mocks import FakeDynamoDBClient

table = Table(
    model,
    client=FakeDynamoDBClient(),
    now=lambda: datetime(2026, 1, 16, tzinfo=timezone.utc),
)