TableTheory Migration Guide
This guide assists in migrating existing Go applications to use TableTheory, focusing on transitions from raw AWS SDK calls or other ORMs.
TypeScript and Python ship their own runtime-specific migration guides as sibling package surfaces in the shared TheoryCloud TableTheory subtree. This page is the Go migration guide.
From Raw AWS SDK for Go (v2)
Problem: Directly using the AWS SDK for Go v2 for DynamoDB operations often leads to verbose code, manual attribute marshaling, and lacks type safety. It also requires explicit context management for every call.
Solution: Replace direct SDK calls with TableTheory’s fluent, model-aware API. TableTheory handles marshaling/unmarshaling, context propagation, and error handling automatically; field-name and operator mistakes are still validated by the runtime rather than by a Go generic compile-time layer.
Example: Creating an Item
// ❌ OLD WAY: Raw AWS SDK v2
package main
import (
"context"
"log"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/aws/aws-sdk-go-v2/config"
)
type User struct {
ID string
Email string
Name string
}
func createUserSDK(ctx context.Context, user User) error {
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-1"))
if err != nil {
return err
}
svc := dynamodb.NewFromConfig(cfg)
item := map[string]types.AttributeValue{
"ID": &types.AttributeValueMemberS{Value: user.ID},
"Email": &types.AttributeValueMemberS{Value: user.Email},
"Name": &types.AttributeValueMemberS{Value: user.Name},
}
_, err = svc.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String("users"),
Item: item,
})
return err
}
func main() {
user := User{ID: "sdk_user_1", Email: "sdk@example.com", Name: "SDK User"}
if err := createUserSDK(context.TODO(), user); err != nil {
log.Fatalf("Failed to create user with SDK: %v", err)
}
log.Println("SDK user created.")
}
// ✅ NEW WAY: TableTheory
package main
import (
"context"
"log"
"github.com/theory-cloud/tabletheory/v2"
"github.com/theory-cloud/tabletheory/v2/pkg/session"
)
type User struct {
ID string `theorydb:"pk" json:"id"`
Email string `theorydb:"sk" json:"email"`
Name string `json:"name"`
}
func createUserTableTheory(ctx context.Context, db tabletheory.DB, user *User) error {
return db.WithContext(ctx).Model(user).Create()
}
func main() {
db, err := tabletheory.New(session.Config{Region: "us-east-1"})
if err != nil {
log.Fatalf("Failed to initialize TableTheory: %v", err)
}
user := &User{ID: "orm_user_1", Email: "orm@example.com", Name: "TableTheory User"}
if err := createUserTableTheory(context.TODO(), db, user); err != nil {
log.Fatalf("Failed to create user with TableTheory: %v", err)
}
log.Println("TableTheory user created.")
}
Benefits of Migrating to TableTheory
- Reduced Boilerplate: Significantly less code required for common CRUD operations.
- Type Safety: Compile-time checks prevent common runtime errors related to attribute names and types.
- Automatic Marshaling: Handles conversion between Go structs and DynamoDB
AttributeValuemaps. - Lambda Optimization: Built-in features for cold-start reduction and connection reuse in serverless environments.
- Fluent API: Chainable methods make queries and transactions more readable and maintainable.
From split LambdaDB/core.DB timeout workarounds
Some Lambda consumers previously needed to keep both a raw *tabletheory.LambdaDB and a lower-level
core.DB/ExtendedDB reference so they could combine Lambda model-cache behavior with a custom timeout buffer.
After upgrading to a TableTheory release that includes LambdaTimeoutConfig, keep a single *tabletheory.LambdaDB:
var db *tabletheory.LambdaDB
func init() {
base, err := tabletheory.LambdaInit(&User{}, &Session{})
if err != nil {
panic(err)
}
db = base.WithLambdaTimeoutConfig(tabletheory.LambdaTimeoutConfig{
Buffer: 500 * time.Millisecond,
})
}
func handler(ctx context.Context) error {
invocationDB := db.WithLambdaTimeout(ctx)
var user User
return invocationDB.Model(&User{}).Where("PK", "=", "USER#1").First(&user)
}
Migration checklist:
- Replace the split raw-
LambdaDB/core.DBholder with one*tabletheory.LambdaDB. - Move the custom buffer into cold-start initialization with
WithLambdaTimeoutConfig. - Keep
WithLambdaTimeout(ctx)in the handler so every invocation gets its own deadline-derived DB. - Validate against the release candidate before stable promotion; this is an additive migration and should not require a data migration.
Go MainExecutor removal
pkg/query.MainExecutor, pkg/query.NewExecutor, and the legacy pkg/query.DynamoDBAPI executor seam were deprecated in
the 1.x line as compatibility/test seams and are removed by the v2 readiness branch. Production callers should construct
models through tabletheory.New(...), tabletheory.LambdaInit(...), and DB.Model(...)/tabletheory.Model(...) so
operations use the maintained runtime executor path.
If application code constructs query.NewExecutor(...) directly, migrate that construction to a normal TableTheory
DB/model flow before adopting v2. If tests used MainExecutor only for DynamoDB AttributeValue unmarshaling coverage,
call query.UnmarshalItem(...) or query.UnmarshalItems(...) directly; if tests need behavior, use NewWithClient(...)
with pkg/testing/fakedb or another implementation of the public tabletheory.DynamoDBAPI seam.
See the v2 migration guide at docs/migration/v2.md
for exact rewrites and downstream coordination.
M6 Go Contract-Parity Compatibility Notes
M6 pins additional cross-runtime contract scenarios for number precision, GSI/projection behavior, pagination, and the type matrix. Two Go parity repairs intentionally change observable Go behavior so Go matches TypeScript, Python, and DynamoDB’s own rules.
Semver decision: treat these Go parity repairs as breaking changes. They must be released only through the next major release lane for this strengthening program, not as a patch or minor release.
ConsistentRead() on GSI queries now fails closed
Go previously accepted .ConsistentRead() on a query that used a Global Secondary Index and then silently omitted the
flag before sending the DynamoDB request. Go now returns ErrInvalidOperator, matching TypeScript and Python, because
DynamoDB GSIs do not support strongly consistent reads.
The blast radius includes both explicit and implicit GSI reads:
.Index("gsi-name").ConsistentRead()now fails before the request is sent whengsi-nameis a GSI.- A query that does not call
.Index(...)can still fail if TableTheory’s optimizer selects a GSI from the supplied key conditions and.ConsistentRead()was also enabled.
Migration checklist:
- Audit Go query builders that combine
.ConsistentRead()with.Index(...). - Audit Go query builders that call
.ConsistentRead()on non-primary-key access patterns; the optimizer may select a GSI even when the code did not name one explicitly. - For GSI read-after-write needs, remove
.ConsistentRead()and use bounded retry/application verification instead. - For truly strong reads, query the base table (or an LSI where applicable) using primary-key conditions rather than a GSI access pattern.
Go binary and set-tagged fields now write canonical DynamoDB shapes
Go writes now converge on the shared type-matrix contract:
[]byte/[]uint8fields write as DynamoDBBinstead of a list of per-byte numbers.- Numeric slices tagged as sets, such as
theorydb:"set"on[]int,[]uint64, or[]float64, write asNS. - Binary set slices tagged as sets, such as
theorydb:"set"on[][]byte, write asBS. - Empty or nil set-tagged slices write as
NULL, preserving DynamoDB’s “no empty set” rule. - Unsupported set-tagged element types now fail at write time instead of falling back to list persistence.
Read compatibility remains shape-driven: Go still reads legacy list-shaped items where the existing unmarshal path
supports that shape. The migration risk is newly heterogeneous data. Tables with old Go-written L values and new
canonical B/NS/BS/NULL values for the same logical field can produce different results for raw DynamoDB
filters, condition expressions, attribute_type checks, and downstream consumers that inspect AttributeValue shapes.
Migration checklist:
- Inventory Go models with
[]bytefields andtheorydb:"set"slices. - Check filters, conditions, GSIs, stream processors, exports, and raw SDK consumers that assume the old list shape.
- If a field must stay homogeneous for raw expressions or analytics, backfill legacy list-shaped values to the
canonical
B,NS,BS, orNULLshape before relying on those expressions. - For unsupported set-tagged element types, remove the
settag to keep list semantics or change the field to one of the supported string, numeric, or binary-set element types.
From Legacy DynamORM
Problem: Legacy DynamORM models often used a mixed naming contract: the primary keys were always stored as uppercase PK and SK, while every other attribute used camelCase. TableTheory historically treated theorydb:"pk" and theorydb:"sk" as roles only, so a model like UserID string 'theorydb:"pk"' would default to the attribute name userID instead of PK.
Solution: Opt into the legacy naming mode with theorydb:"naming:dynamorm". In that mode, TableTheory preserves PK and SK for the table keys and continues to derive camelCase names for non-key fields unless you override them with attr:.
// ❌ BEFORE: default naming would map keys to userID/entity
type LegacyUser struct {
UserID string `theorydb:"pk" json:"PK"`
Entity string `theorydb:"sk" json:"SK"`
FirstName string `json:"firstName"`
}
// ✅ AFTER: DynamORM-compatible naming keeps PK/SK uppercase
type LegacyUser struct {
_ struct{} `theorydb:"naming:dynamorm"`
UserID string `theorydb:"pk" json:"PK"`
Entity string `theorydb:"sk" json:"SK"`
FirstName string `json:"firstName"`
}
This mode is intended for first-class support of legacy tables. Use it when all of these are true:
- the table keys must remain uppercase
PKandSK - non-key attributes should continue to use camelCase
- you want queries, unmarshaling, schema generation, and DMS metadata to agree on that contract without repeating
attr:PKandattr:SKeverywhere
From Other ORMs (e.g., GORM for SQL)
Problem: SQL ORMs are designed for relational databases and do not translate well to DynamoDB’s NoSQL, key-value, and document-oriented model. Concepts like joins and complex secondary indexes are fundamentally different.
Solution: Adapt your data models and query patterns to be DynamoDB-native. TableTheory provides an ORM-like experience while respecting DynamoDB’s strengths.
Key Differences and Adaptations
- Data Modeling: Think about Partition Keys (PK) and Sort Keys (SK) for efficient access patterns, not just primary keys.
- SQL:
id INT PRIMARY KEY,name VARCHAR(255) - DynamoDB (TableTheory):
ID stringtheorydb:”pk”,SK stringtheorydb:"sk"
- SQL:
-
Joins: DynamoDB does not support joins. Denormalize data or use multiple
BatchGetcalls. - Querying: Prioritize queries by PK/SK. Use Global Secondary Indexes (GSIs) for alternate access patterns.
// ❌ OLD WAY: GORM (SQL-like)
func getActiveUsersGORM(db *gorm.DB) ([]User, error) {
var users []User
result := db.Where("status = ?", "active").Find(&users)
return users, result.Error
}
// ✅ NEW WAY: TableTheory (DynamoDB-native)
// Assumes a GSI named "status-index" with 'Status' as its PK
func getActiveUsersTableTheory(db tabletheory.DB) ([]User, error) {
var users []User
err := db.Model(&User{}).
Index("status-index"). // Explicitly use the GSI
Where("Status", "=", "active").
All(&users)
return users, err
}
Go Encrypted Read Fail-Closed Restoration
Problem: A recent Go compatibility path allowed encrypted-tag reads to accept legacy plaintext when
THEORYDB_ENCRYPTED_STRICT=false. That softened TableTheory’s fail-closed encryption model by allowing
non-envelope values to bypass decryption and flow into application structs as accepted plaintext.
Current contract: Go encrypted-tag reads are fail-closed again. Encrypted fields must be stored as
valid TableTheory encryption envelopes. If a read encounters plaintext or any other non-envelope value for
an encrypted field, the read returns an EncryptedFieldError that wraps
pkg/errors.ErrInvalidEncryptedEnvelope.
Migration impact
THEORYDB_ENCRYPTED_STRICT=falseno longer permits plaintext fallback for encrypted-tag reads.- Deployments that still have legacy plaintext in encrypted attributes must backfill those records into valid envelopes before upgrading to this behavior.
- If plaintext remains in storage, reads now fail instead of silently hydrating application models with accepted plaintext.
Safe upgrade path
- Identify every encrypted attribute that may still contain legacy plaintext.
- Backfill those attributes into valid TableTheory envelopes with the intended KMS key.
- Verify the backfill before rollout so production reads do not trip
EncryptedFieldErrorafter upgrade. - Remove any operational reliance on
THEORYDB_ENCRYPTED_STRICT=false; it is no longer a supported compatibility escape hatch for encrypted-tag reads.
Go Anonymous Embedded Struct Helper Compatibility
Problem: Some Go helper surfaces historically walked only direct struct fields. For models with exported anonymous embedded
base structs, flat payloads such as id, type, and to could hydrate through the metadata-driven ORM path while generic
helper decoders looked only for a nested anonymous-container map such as BaseObject: { id, type, to }.
Current compatibility contract: Go helper reads now accept both shapes, and default helper writes stay legacy-safe.
What is guaranteed now
- Broadened decode support: Go helper decoders accept both:
- flat promoted-field payloads such as
id,type,to,actor,object - legacy nested anonymous-container payloads such as
BaseObject: { id, type, to }
- flat promoted-field payloads such as
- Public helper coverage:
tabletheory.UnmarshalItem(...)tabletheory.UnmarshalStreamImage(...)pkg/types.Converter.FromAttributeValue(...)
- Query helper coverage: Named Go field updates such as
Update("Status")and batch helper key/field selection now resolve promoted fields that live on exported anonymous embedded structs. - Default write compatibility: Historical helper paths that encoded anonymous embedded structs under a container such as
BaseObjectcontinue to do so by default. This repair does not silently flatten helper write output. - Anonymous-embed hook precedence: When an anonymous embedded field has a registered custom converter or a
MarshalDynamoDBAttributeValue()hook, Go helper write paths marshal that embedded container through the hook before any promoted-field traversal or flat anonymous-embed encoding is applied.
Opting in to flat helper writes
New Go code can now request flat promoted-field helper encoding explicitly:
converter := pkgtypes.NewConverter().WithFlatAnonymousEmbedEncoding()
av, err := converter.ToAttributeValue(activity)
if err != nil {
return err
}
q := query.New(&activity, metadata, executor).WithConverter(converter)
_ = av
_ = q
This opt-in is additive:
pkg/types.Converter.ToAttributeValue(...)flattens exported promoted fields from anonymous embeds- helper surfaces that accept the configured converter (for example
Query.WithConverter(...)and marshaler factories built with that converter) use the same flat helper encoding - default helper writes stay legacy-compatible when you do nothing
Why this is the safe path
TableTheory is already used by production systems. The lowest-risk repair is therefore:
- broaden reads so old and new payload shapes both hydrate correctly
- preserve the current default helper write shape
- make any future flat helper encode mode additive and explicit rather than silent
This avoids mixed-deployment write-shape surprises while still repairing real-world decode failures.
Verification coverage
This compatibility contract is pinned by:
- focused Go regression coverage in
pkg/types,pkg/query,internal/expr, andpkg/marshal - public API verification for
tabletheory.UnmarshalItem(...)andtabletheory.UnmarshalStreamImage(...) - integration coverage for promoted-field query/update behavior on embedded models
Release posture
This repair is intended to remain patch-compatible on the current line:
- decode support broadens
- default helper writes remain unchanged
- no default encode-shape flip is part of this repair
Flat helper encoding now exists only as an explicit opt-in for new Go consumers. Any future default encode-shape convergence would still require migration notes and downstream coordination before it could even be considered for a future major release.