Mutations
Learn how to create, update, and delete data in DynamoDB using Dynatable's type-safe mutation operations.
Put (Create/Replace)
The put operation creates a new item or replaces an existing one.
Basic Put
const user = await table.entities.User.put({
username: 'alice',
name: 'Alice Smith',
email: 'alice@example.com',
bio: 'Software engineer',
}).execute();
console.log(user);
// {
// username: 'alice',
// name: 'Alice Smith',
// email: 'alice@example.com',
// bio: 'Software engineer',
// createdAt: '2024-01-15T10:00:00.000Z', // ISO 8601 string
// updatedAt: '2024-01-15T10:00:00.000Z',
// }
Put with Auto-Generated ID
const post = await table.entities.Post.put({
username: 'alice',
title: 'My First Post',
content: 'Hello, World!',
}).execute();
console.log(post.postId); // Auto-generated ULID
Put with Defaults
// Schema with defaults
Post: {
attributes: {
username: { type: String, required: true },
title: { type: String, required: true },
published: { type: Boolean, default: false },
views: { type: Number, default: 0 },
}
}
// Put without specifying defaults
const post = await table.entities.Post.put({
username: 'alice',
title: 'My Post',
}).execute();
console.log(post.published); // false (default)
console.log(post.views); // 0 (default)
Conditional Put
Only create if item doesn't exist:
await table.entities.User.put({
username: 'alice',
name: 'Alice Smith',
email: 'alice@example.com',
})
.ifNotExists()
.execute();
Custom condition:
await table.entities.User.put({
username: 'alice',
name: 'Alice Updated',
email: 'alice@example.com',
})
.where((attr, op) => op.eq(attr.version, 1))
.execute();
Update
The update operation modifies specific attributes of an existing item.
Set Attributes
await table.entities.User.update({
username: 'alice',
})
.set('name', 'Alice Johnson')
.set('bio', 'Senior software engineer')
.execute();
Multiple sets via chained calls:
await table.entities.User.update({
username: 'alice',
})
.set('name', 'Alice Johnson')
.set('email', 'alice.johnson@example.com')
.set('bio', 'Senior engineer')
.execute();
Or pass a Partial<Model> object to set several attributes in one call:
await table.entities.User.update({
username: 'alice',
})
.set({
name: 'Alice Johnson',
email: 'alice.johnson@example.com',
bio: 'Senior engineer',
})
.execute();
Both forms are equivalent. The object form is convenient when patching from a partial DTO. Any attribute name is accepted, including name.
.set(), .setIfNotExists(), .add() and .delete() reject undefined values up front. DynamoDB cannot encode undefined in ExpressionAttributeValues, so the builder throws with the offending keys instead of letting the request fail server-side with an opaque ValidationException. Use .remove(attr) to clear an attribute, or filter undefined out of your payload before calling .set(). For the external-sync upsert pattern (where undefined means "clear this attribute"), use setDefined instead. null is allowed — it writes the DynamoDB NULL type.
Set If Not Exists (Immutable on Upsert)
Use setIfNotExists to write an attribute only on first insert. Maps to DynamoDB's if_not_exists() SET function: the value is kept if the attribute already exists on the item. The canonical case is an immutable createdAt timestamp on an upsert:
const now = new Date().toISOString();
await table.entities.User.update({
username: 'alice',
})
.set('lastSeenAt', now)
.setIfNotExists('createdAt', now)
.execute();
The object form sets several immutable-on-create fields at once:
await table.entities.Order.update({
orderId: 'ord_42',
})
.set('status', 'shipped')
.setIfNotExists({ createdAt: now, createdBy: 'alice' })
.execute();
setIfNotExists is rejected by the builder when the field participates in any primary-key or secondary-index template. The resolved value is decided by DynamoDB at write time, so the index key cannot be recomputed safely — allowing it would silently corrupt the index whenever the conditional write keeps the existing value. Either keep the immutable field out of every key template, or perform a get + conditional .set() in two steps.
Combining .set('foo', ...) and .setIfNotExists('foo', ...) on the same attribute is rejected before the request leaves the process (DynamoDB would reject it as overlapping document paths).
Set Defined (Upsert from External Source)
Use setDefined for the external-sync upsert pattern: defined fields are written via SET, keys whose value is undefined are removed from the item. null stays defined and writes the DynamoDB NULL type.
This is the right tool when syncing from a system (cron job, third-party API, partial DTO) where a missing field in the source payload means "this attribute no longer applies" — as opposed to .set(), which is strict and rejects undefined because it cannot know whether the caller wanted to clear the attribute or skip it:
const fields = {
name: tamsRecord.name, // SET
email: tamsRecord.email, // may be undefined → REMOVE
lastSeenAt: new Date().toISOString(),
};
await table.entities.User.update({ username: 'alice' })
.setDefined(fields)
.setIfNotExists('createdAt', new Date().toISOString())
.execute();
Internally, setDefined splits the payload and routes through .set() and .remove(), so every existing guard applies identically: primary-key template immutability, secondary-index template guards, dedup, and GSI key recomputation.
Because setDefined routes undefined keys to .remove(), passing undefined for a field that participates in a secondary-index template surfaces the existing GSI guard: the index key cannot be recomputed without an explicit value, and the builder rejects the update. Either pass a concrete value for that field or restructure the schema so the field is not part of any GSI template.
setDefined is opt-in by design. .set() deliberately rejects undefined rather than auto-mapping to REMOVE, because undefined does not carry a single universal meaning across callers (an external-sync wants REMOVE; a partial PATCH wants no-op; a programmer bug should throw). A named method makes the intent explicit at the call site.
Add (Increment/Decrement)
Increment a number:
await table.entities.Post.update({
username: 'alice',
postId: 'post123',
})
.add('views', 1)
.execute();
Decrement:
await table.entities.User.update({
username: 'alice',
})
.add('credits', -10)
.execute();
Remove Attributes
await table.entities.User.update({
username: 'alice',
})
.remove('bio')
.remove('website')
.execute();
Combined Operations
await table.entities.User.update({
username: 'alice',
})
.set('name', 'Alice Johnson')
.add('loginCount', 1)
.remove('temporaryFlag')
.execute();
Conditional Update
Update only if condition is met:
await table.entities.User.update({
username: 'alice',
})
.set('name', 'Alice Johnson')
.where((attr, op) => op.eq(attr.status, 'active'))
.execute();
Multiple conditions:
await table.entities.User.update({
username: 'alice',
})
.set('role', 'admin')
.where((attr, op) => op.and(op.eq(attr.isVerified, true), op.gt(attr.loginCount, 100)))
.execute();
Return Values
Get the updated item:
const updated = await table.entities.User.update({
username: 'alice',
})
.set('name', 'Alice Johnson')
.returning('ALL_NEW')
.execute();
console.log(updated); // Complete updated item
Return options:
NONE: Don't return anything (default)ALL_OLD: Return item before updateALL_NEW: Return item after updateUPDATED_OLD: Return only updated attributes (old values)UPDATED_NEW: Return only updated attributes (new values)
// Get old values
const old = await table.entities.User.update({
username: 'alice',
})
.set('name', 'Alice Johnson')
.returning('ALL_OLD')
.execute();
// Get only what changed
const changes = await table.entities.User.update({
username: 'alice',
})
.set('name', 'Alice Johnson')
.set('email', 'alice.j@example.com')
.returning('UPDATED_NEW')
.execute();
Delete
The delete operation removes an item from the table.
Basic Delete
await table.entities.User.delete({
username: 'alice',
}).execute();
With composite key:
await table.entities.Post.delete({
username: 'alice',
postId: 'post123',
}).execute();
Conditional Delete
Delete only if condition is met:
await table.entities.User.delete({
username: 'alice',
})
.where((attr, op) => op.eq(attr.status, 'inactive'))
.execute();
Multiple conditions:
await table.entities.Post.delete({
username: 'alice',
postId: 'post123',
})
.where((attr, op) => op.and(op.eq(attr.published, false), op.lt(attr.views, 10)))
.execute();
Return Deleted Item
const deleted = await table.entities.User.delete({
username: 'alice',
})
.returning('ALL_OLD')
.execute();
console.log(deleted); // The deleted item
Soft Delete
Instead of deleting, mark as deleted:
await table.entities.User.update({
username: 'alice',
})
.set('isDeleted', true)
.set('deletedAt', new Date())
.execute();
Batch Write
Perform multiple put operations in a single request. Each entity exposes batchWrite(items) which issues a BatchWriteItem containing only put requests:
await table.entities.User.batchWrite([
{ username: 'alice', name: 'Alice', email: 'alice@example.com' },
{ username: 'bob', name: 'Bob', email: 'bob@example.com' },
{ username: 'charlie', name: 'Charlie', email: 'charlie@example.com' },
]).execute();
For deletes or for mixing puts and deletes atomically, use a transaction (see below). DynamoDB's native BatchWriteItem does not support condition expressions and cannot be combined with conditional checks — if you need atomicity or conditions, use transactWrite().
entities.X.batchWrite() (DynamoDB BatchWriteItem):
- Max 25 items per chunk; the builder chunks larger arrays automatically
- Max 16 MB total per chunk
- Puts only via
entities.X.batchWrite()(no conditional expressions) - Unprocessed items are retried with exponential backoff; remaining failures
surface as
BatchUnprocessedError
For reads, entities.X.batchGet() allows up to 100 keys per chunk.
Transactions
Perform atomic operations across multiple items.
Transact Write
Atomic create/update/delete:
await table
.transactWrite()
.addPut(
table.entities.User.put({
username: 'alice',
name: 'Alice Smith',
email: 'alice@example.com',
}).dbParams()
)
.addUpdate(
table.entities.User.update({
username: 'bob',
})
.add('followerCount', 1)
.dbParams()
)
.addDelete(
table.entities.Post.delete({
username: 'charlie',
postId: 'post123',
}).dbParams()
)
.execute();
Transaction with Conditions
All operations must succeed or all fail:
await table
.transactWrite()
.addPut(
table.entities.Post.put({
username: 'alice',
title: 'New Post',
content: 'Content...',
}).dbParams()
)
.addUpdate(
table.entities.User.update({
username: 'alice',
})
.add('postCount', 1)
.where((attr, op) => op.lt(attr.postCount, 100))
.dbParams()
)
.execute();
Complex Transaction
// Transfer credits between users atomically
await table
.transactWrite()
.addUpdate(
table.entities.User.update({
username: 'alice',
})
.add('credits', -100)
.where((attr, op) => op.gte(attr.credits, 100))
.dbParams()
)
.addUpdate(
table.entities.User.update({
username: 'bob',
})
.add('credits', 100)
.dbParams()
)
.execute();
Transactions:
- Max 100 items per transaction
- Max 4 MB total data
- All succeed or all fail (atomic)
- Higher cost than individual operations
Optimistic Locking
Prevent concurrent update conflicts using version numbers:
// 1. Read current version
const user = await table.entities.User.get({
username: 'alice',
}).execute();
const currentVersion = user.version;
// 2. Update with version check
try {
await table.entities.User.update({
username: 'alice',
})
.set('name', 'Alice Updated')
.add('version', 1)
.where((attr, op) => op.eq(attr.version, currentVersion))
.execute();
} catch (error) {
// Version mismatch - someone else updated it
console.error('Concurrent update detected');
}
Timestamps
With timestamps: true in schema, Dynatable automatically manages:
createdAt: Set once on creationupdatedAt: Updated on every put/update
// Put - sets both timestamps
const user = await table.entities.User.put({
username: 'alice',
name: 'Alice Smith',
}).execute();
console.log(user.createdAt); // "2024-01-15T10:00:00.000Z" (ISO string)
console.log(user.updatedAt); // "2024-01-15T10:00:00.000Z" (ISO string)
// Update - only updates updatedAt
await table.entities.User.update({
username: 'alice',
})
.set('name', 'Alice Johnson')
.execute();
// createdAt unchanged, updatedAt updated
The automatic createdAt only fires on .put(). If you're using .update() as an upsert (the item may or may not exist yet), use setIfNotExists to write createdAt only on the first insert.
Error Handling
Conditional Check Failures
try {
await table.entities.User.put({
username: 'alice',
name: 'Alice',
})
.ifNotExists()
.execute();
} catch (error) {
if (error.name === 'ConditionalCheckFailedException') {
console.error('User already exists');
}
}
Validation Errors
try {
await table.entities.User.put({
username: 'alice',
email: 'not-an-email', // Invalid
}).execute();
} catch (error) {
console.error('Validation failed:', error.message);
}
Transaction Failures
try {
await table.transactWrite().addUpdate(/* ... */).addUpdate(/* ... */).execute();
} catch (error) {
if (error.name === 'TransactionCanceledException') {
console.error('Transaction failed');
// All operations rolled back
}
}
Best Practices
1. Use Update Instead of Get + Put
// ❌ Bad - two operations
const user = await table.entities.User.get({ username: 'alice' }).execute();
user.loginCount += 1;
await table.entities.User.put(user).execute();
// ✅ Good - single atomic operation
await table.entities.User.update({ username: 'alice' }).add('loginCount', 1).execute();
2. Use Transactions for Related Updates
// ✅ Good - atomic
await table
.transactWrite()
.addPut(
table.entities.Post.put({
username: 'alice',
title: 'New Post',
}).dbParams()
)
.addUpdate(table.entities.User.update({ username: 'alice' }).add('postCount', 1).dbParams())
.execute();
3. Use Batch for Multiple Independent Operations
// ✅ Good - batch write
await table.entities.User.batchWrite([
{ username: 'alice', name: 'Alice' },
{ username: 'bob', name: 'Bob' },
{ username: 'charlie', name: 'Charlie' },
]).execute();
// ❌ Bad - multiple individual writes
await table.entities.User.put({ username: 'alice', name: 'Alice' }).execute();
await table.entities.User.put({ username: 'bob', name: 'Bob' }).execute();
await table.entities.User.put({
username: 'charlie',
name: 'Charlie',
}).execute();
4. Implement Soft Deletes for Important Data
// ✅ Good - soft delete (recoverable)
await table.entities.User.update({ username: 'alice' })
.set('isDeleted', true)
.set('deletedAt', new Date())
.execute();
// ❌ Risky - hard delete (permanent)
await table.entities.User.delete({ username: 'alice' }).execute();
5. Use Conditional Writes
// ✅ Good - prevent overwriting
await table.entities.User.put({
username: 'alice',
name: 'Alice',
})
.ifNotExists()
.execute();
// ❌ Bad - might overwrite existing data
await table.entities.User.put({
username: 'alice',
name: 'Alice',
}).execute();
6. Handle Errors Appropriately
try {
await table.entities.User.update({ username: 'alice' })
.set('name', 'Alice Johnson')
.where((attr, op) => op.eq(attr.version, currentVersion))
.execute();
} catch (error) {
if (error.name === 'ConditionalCheckFailedException') {
// Retry with fresh data
const user = await table.entities.User.get({ username: 'alice' }).execute();
// Handle optimistic lock failure
} else {
throw error;
}
}
Common Patterns
Counter
await table.entities.Post.update({
username: 'alice',
postId: 'post123',
})
.add('views', 1)
.execute();
Toggle Boolean
const user = await table.entities.User.get({ username: 'alice' }).execute();
await table.entities.User.update({ username: 'alice' }).set('isActive', !user.isActive).execute();
Append to List
const user = await table.entities.User.get({ username: 'alice' }).execute();
await table.entities.User.update({ username: 'alice' })
.set('loginHistory', [...(user.loginHistory || []), new Date()])
.execute();
Upsert (Put with defaults)
await table.entities.User.put({
username: 'alice',
name: 'Alice Smith',
email: 'alice@example.com',
loginCount: 1,
}).execute();
// If exists, it replaces
// If not, it creates with defaults