Latest Version: 22.0.0
simplesingletable is a Python library providing an abstraction layer for AWS DynamoDB operations, specifically designed for single-table design patterns. The library uses Pydantic for model definitions and includes " batteries-included" functionality for common DynamoDB use cases.
- Small to medium scale applications
- Single-table DynamoDB design patterns
- Applications requiring versioned resources with automatic history tracking
- Fast, consistent, and cost-effective storage solutions
- Single Table Design: Store different object types in a single DynamoDB table
- Automatic ID Generation: Uses lexicographically sortable IDs via
ulid-pyfor chronological ordering - Resource Versioning: Automatic versioning with complete history and optimistic concurrency control
- Secondary Access Patterns: Support for GSI-based queries and filtering
- Pydantic Integration: Type-safe models with validation
- Compression Support: Optional gzip compression for large data
BaseDynamoDbResource: Abstract base class for all resourcesDynamoDbResource: Non-versioned resources (simpler, lighter)DynamoDbVersionedResource: Versioned resources with automatic history trackingPaginatedList: Enhanced list for paginated query results
DynamoDbMemory: Main interface for DynamoDB operations (CRUD, queries, filtering)
- ID generation, pagination helpers, DynamoDB type marshalling
Non-Versioned Resources (DynamoDbResource):
- Lighter weight, direct updates
- No version history
- Fields:
resource_id,created_at,updated_at
Versioned Resources (DynamoDbVersionedResource):
- Complete version history
- Optimistic concurrency control
- Version limit enforcement (configurable via
max_versions) - Compressed data storage by default
- Fields:
resource_id,version,created_at,updated_at
Declare each index with a GsiIndex, keyed by the deployed index name. It states the index's
partition and sort attributes outright, so writes, pagination keys and validation all come from the
declaration rather than from naming conventions:
from simplesingletable import GsiIndex
class MyResource(DynamoDbVersionedResource):
owner_id: str
assignee: str | None = None
# Static configuration
gsi_config: ClassVar[dict[str, GsiIndex]] = {
# On the standard table, gsi1 and gsi2 range on the table's own pk: say so with sk="pk"
"gsi1": GsiIndex(pk=("gsi1pk", lambda self: f"owner#{self.owner_id}"), sk="pk"),
# An index with its own sort attribute; sparse, since the pk is None when unassigned
"gsi3": GsiIndex(
pk=("gsi3pk", lambda self: self.assignee and f"assignee#{self.assignee}"),
sk=("gsi3sk", lambda self: self.created_at.isoformat()),
description="by assignee",
),
}
# Or dynamic configuration via get_gsi_config()
@classmethod
def get_gsi_config(cls) -> dict[str, GsiIndex]:
return {"gsi-2": GsiIndex.from_pair("gsi2pk", "gsi2sk", lambda self: (f"owner#{self.owner_id}", self.resource_id))}skis a(attribute, function)pair for a sort attribute the library writes,"pk"/"sk"for an index that ranges on the table's own key, orNonefor a hash-only index.GsiIndex.from_pair(pk_attr, sk_attr, fn)takes one function returning(pk, sk)orNone.- An entry is written all-or-nothing: if any of its values is
None,""orFalse, none of its attributes are written (andrecompute_gsis=Truein a transaction REMOVEs them).0is written, so a value function's type must match the attribute type the table declares. descriptionis free text for the access pattern; the dict key is always the index name.- The built-in type index cannot be declared: a
GsiIndexkeyed"gsitype"raisesGsiConfigError(customise it withdb_get_gsitypepk/db_get_gsitypesk). - When migrating, delete each
db_get_gsi1pk/db_get_gsi2pk/db_get_gsi3pk_and_skoverride as you add theGsiIndexthat replaces it. AGsiIndexwriting an attribute that a still-overridden hook also writes raisesGsiConfigError(kindlegacy_hook_conflict) on every write and read, since the hook would silently overwrite the declared value.
Validating against your table. DynamoDbMemory.validate_gsi_config(*classes) compares the
declarations with the table's DescribeTable key schemas and raises GsiConfigError (with
.problems) on any mismatch, so a wrong index name or key schema fails CI instead of breaking
pagination in production. AuditLog is checked against the audit table. LocalStorageMemory has
no equivalent, since it has no table schema.
def test_gsi_config_matches_table(memory):
memory.validate_gsi_config(Thing, Order, AuditLog)Deprecated: the dict form ({"label": {"gsi1pk": fn}}, including tuple keys) and overriding the
db_get_gsi1pk / db_get_gsi2pk / db_get_gsi3pk_and_sk hooks still work, but bind attributes
to indexes by inference and emit a DeprecationWarning. They will be removed in the next major
release.
Additional patterns and utilities:
- Repository Pattern:
ResourceRepositoryfor higher-level operations - Versioned Repository:
VersionedResourceRepositorywith version management - Singleton Pattern: For configuration resources
- Form Data: Streamlit integration helpers
- Habit Tracker: Example application
Core Dependencies:
boto3- AWS SDKpydantic>2- Data validation and serializationulid-py- Unique ID generationhumanize- Human-readable formatting
Development Dependencies:
pytest+pytest-cov+pytest-docker- Testing frameworkblack+isort+ruff- Code formatting and lintinginvoke- Task automationbumpver- Version management
The project uses invoke for task automation (tasks.py):
# Dependency management
inv compile-requirements # Compile requirements.txt from pyproject.toml
inv compile-requirements --upgrade # Update dependencies
# Development
inv lint # Format and lint code (black, isort, ruff)
inv launch-dynamodb-local # Start local DynamoDB for testing
inv halt-dynamodb-local # Stop local DynamoDB
# Testing
pytest # Run test suite with coverage
pytest tests/test_specific.py # Run specific tests
# Release management
inv bumpver --patch|minor|major # Bump version
inv build # Build distribution packages
inv fullrelease --patch # Complete release cycle (lint, test, bump, build, publish)- Docker-based: Uses DynamoDB Local via Docker Compose
- Pytest fixtures:
conftest.pyprovides database setup/teardown - Comprehensive coverage: Tests for CRUD, versioning, GSI, filtering
- Test files:
test_simplesingletable.py- Core functionalitytest_versioned_repository.py- Version managementtest_repository.py- Repository patterntest_filter_expressions.py- Query filtering
from simplesingletable import DynamoDbVersionedResource, GsiIndex
class MyResource(DynamoDbVersionedResource):
name: str
status: str
metadata: Optional[dict] = None
# the following are defined on the base class
# resource_id: str
# version: int
# created_at: datetime
# updated_at: datetime
# Optional: Configure compression and version limits
resource_config: ClassVar[ResourceConfig] = ResourceConfig(
compress_data=True, # Default for versioned
max_versions=10 # Keep only 10 versions
)
# Optional: GSI configuration for secondary access patterns
@classmethod
def get_gsi_config(cls) -> dict[str, GsiIndex]:
return {"gsi1": GsiIndex(pk=("gsi1pk", lambda self: f"status#{self.status}"), sk="pk")}A subclass's resource_config is shallow-merged over its nearest parent's effective config,
and a class that declares none inherits its parent's unchanged, so a shared domain base class
can set defaults like compress_data=False for everything below it. Restating blob_fields or
audit_config replaces the parent's whole nested dict.
By default, Pydantic fields set to None are written to DynamoDB as {"NULL": True}
attributes. Boto3's wire protocol treats those attributes as present, which means
attribute_not_exists(field) returns False after the very first PUT — even on a
freshly created resource where the field was never set. This is the dominant
foot-gun for the standard "claim this slot" pattern:
class Asset(DynamoDbResource):
asset_tag: str
assigned_user_id: Optional[str] = None
memory.create_new(Asset, {"asset_tag": "X"})
# Without omit_none_attributes, this rejects every time:
memory.update_existing(
asset, {"assigned_user_id": "alice"},
condition="attribute_not_exists(assigned_user_id)",
)Set omit_none_attributes=True on the resource's ResourceConfig to drop
None-valued fields before marshalling. The natural attribute_not_exists
pattern then works as expected:
class Asset(DynamoDbResource):
resource_config: ClassVar[ResourceConfig] = ResourceConfig(
omit_none_attributes=True,
)
asset_tag: str
assigned_user_id: Optional[str] = NoneThe flag is off by default for backward compatibility, but is recommended for any
resource that uses Optional fields as slot markers or relies on
attribute_not_exists in conditional updates. The flag has no effect on
compressed (versioned) resources, since their fields live inside the gzipped
data blob rather than as separate DynamoDB attributes.
from simplesingletable import DynamoDbMemory
memory = DynamoDbMemory(logger=logger, table_name="my-table")
# Create
resource = memory.create_new(MyResource, {"name": "test", "status": "active"})
# Read
retrieved = memory.read_existing(resource.resource_id, MyResource)
# Update (versioned resources automatically increment version)
updated = memory.update_existing(retrieved, {"status": "inactive"})
# List with filtering
resources = memory.list_resources(
MyResource,
filter_fn=lambda r: r.status == "active",
limit=50
)The library provides powerful paginated query capabilities for efficient data retrieval from DynamoDB, supporting both primary key and GSI-based queries with filtering.
from simplesingletable import exhaust_pagination
# Direct paginated query with GSI
results = memory.paginated_dynamodb_query(
resource_class=MyResource,
index_name="gsi1",
key_condition=Key("gsi1pk").eq("status#active"),
results_limit=100, # Items per page
pagination_key=None # For first page
)
# Get all results (automatically handles pagination)
all_results = []
for page in exhaust_pagination(
lambda pk=None: memory.paginated_dynamodb_query(
resource_class=MyResource,
index_name="gsi1",
key_condition=Key("gsi1pk").eq("status#active"),
results_limit=100,
pagination_key=pk
)
):
all_results.extend(page)Define query methods on your resource classes for reusable access patterns:
class MyResource(DynamoDbVersionedResource):
status: str
category: str
@classmethod
def get_gsi_config(cls) -> dict[str, GsiIndex]:
return {
# Sparse GSI - only index active items
"gsi1": GsiIndex(
pk=(
"gsi1pk",
lambda self: f"{self.get_unique_key_prefix()}#{self.category}" if self.status == "active" else None,
),
sk="pk",
),
}
@classmethod
def query_by_category_kwargs(cls, category: str):
"""Build query kwargs for category-based queries."""
return {
"index_name": "gsi1",
"key_condition": Key("gsi1pk").eq(f"{cls.get_unique_key_prefix()}#{category}")
}
# Use the query pattern
active_in_category = memory.paginated_dynamodb_query(
resource_class=MyResource,
**MyResource.query_by_category_kwargs("important"),
results_limit=50
)Important Note on Versioned Resources: Versioned resources use compression by default, which means DynamoDB filter expressions can only access attributes that are part of a GSI (pk, sk, gsi1pk, gsi1sk, etc.). For compressed resources, attribute-based filtering must be done client-side. Non-versioned resources don't have this limitation.
from boto3.dynamodb.conditions import Attr
# For NON-VERSIONED resources - DynamoDB-side filtering works
filter_expression = Attr("status").eq("active") & Attr("priority").gt(5)
# For VERSIONED resources - use client-side filtering or GSI design
def custom_filter(resource: MyVersionedResource) -> bool:
return resource.status == "active" and resource.priority > 5
# Combined approach (versioned resource example)
results = memory.paginated_dynamodb_query(
resource_class=MyVersionedResource,
filter_fn=custom_filter, # Client-side only for versioned
results_limit=100
)Best Practices for Efficient Filtering:
- Design GSIs carefully - For versioned resources, encode filterable attributes in GSI keys
- Consider data volume - Client-side filtering is acceptable for small result sets (< 1000 items)
- Use
list_type_by_updated_at- Efficient for time-based queries with limited client-side filtering - Non-versioned for high-volume filtering - Consider using non-versioned resources when you need extensive DynamoDB-side filtering
For UI-driven pagination or batch processing:
# First page
page1 = memory.paginated_dynamodb_query(
resource_class=MyResource,
results_limit=20
)
# Get pagination key for next page
if page1.has_more:
next_key = page1.pagination_key
# Fetch next page
page2 = memory.paginated_dynamodb_query(
resource_class=MyResource,
results_limit=20,
pagination_key=next_key
)Special helper for time-based queries:
# List resources by most recently updated
recent = memory.list_type_by_updated_at(
MyResource,
results_limit=50,
filter_expression=Attr("status").eq("pending")
)# Get all versions of a resource
versions = memory.get_resource_versions(resource_id, MyResource)
# Get specific version
v2 = memory.read_existing_version(resource_id, MyResource, version=2)
# Version limits automatically enforced during updates
# (configure via resource_config['max_versions'])simplesingletable stores its own internal resources alongside your application
data, namespaced under _INTERNAL. This keeps the library's bookkeeping
out of the way of your resource type prefixes (which use the class name) but
still lives in the same table so it benefits from single-table-design queries.
| Resource | pk | Purpose |
|---|---|---|
MemoryStats |
_INTERNAL#MemoryStats |
Counter of items per resource type; visible via memory.get_stats(). |
AuditLog |
_INTERNAL#AuditLog#<ULID> |
One row per CREATE/UPDATE/DELETE/RESTORE on an audit-enabled resource. |
When troubleshooting via aws dynamodb scan, filter on begins_with(pk, "_INTERNAL#")
to see what the library is storing. Audit rows can also be configured to live in a
separate table — see audit_table_name on DynamoDbMemory if your app produces
enough audit volume that you want to isolate it.
To enable audit logging for a resource, set audit_config on its resource_config:
from simplesingletable import DynamoDbResource
from simplesingletable.models import AuditConfig, ResourceConfig
class User(DynamoDbResource):
resource_config: ClassVar[ResourceConfig] = ResourceConfig(
audit_config=AuditConfig(
enabled=True,
track_field_changes=True, # populate `changed_fields` on UPDATEs
include_snapshot=True, # populate `resource_snapshot` on every row
),
)
email: str
role: Optional[str] = NoneSee the AuditConfig docstring for the full "what you get" matrix — in short,
track_field_changes=True only emits a diff if the caller supplies an
old_resource (the non-transactional path always does; for transactions, use
txn.read(...) or pass current=resource to txn.update(...)).
AuditLogQuerier exposes the standard access patterns:
from simplesingletable import AuditLogQuerier
querier = AuditLogQuerier(memory)
# All changes to a specific resource
logs = querier.get_logs_for_resource("User", user_id)
# All changes by a user — backed by a sparse GSI on changed_by, no scan.
edits_by_admin = querier.get_logs_by_changer("admin@example.com")- Line length: 120 characters
- Python version: ≥3.10
- Type hints: Required for all public APIs
- Documentation: Docstrings for classes and complex methods
- Testing: High coverage requirements (see
pyproject.toml)
src/simplesingletable/__init__.py- Public API exportssrc/simplesingletable/models.py- Core resource classes and typessrc/simplesingletable/dynamodb_memory.py- Main DynamoDB interfacesrc/simplesingletable/utils.py- Utility functionssrc/simplesingletable/extras/- Additional patterns and examplestests/- Comprehensive test suitetasks.py- Development automation scriptspyproject.toml- Project configuration and dependencies
- Versioned vs Non-versioned: Choose based on whether you need history tracking
- Compression: Versioned resources use compression by default; configure via
resource_config - GSI Limits: DynamoDB has GSI limits; design access patterns carefully
- Version Limits: Set
max_versionsto prevent unbounded growth - Pagination: Use
exhaust_pagination()for complete result sets - Concurrency: Versioned resources prevent concurrent updates from same version