Skip to main content

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();
note

Both forms are equivalent. The object form is convenient when patching from a partial DTO. Any attribute name is accepted, including name.

caution

.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();
caution

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.

note

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.

caution

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.

note

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 update
  • ALL_NEW: Return item after update
  • UPDATED_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().

note

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();
note

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 creation
  • updatedAt: 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
note

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();
// ✅ 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

Next Steps

  • Queries - Learn how to retrieve your data
  • Examples - See complete CRUD examples