On any DynamoDB table that has a vector index (GA 2026-08-05), an item whose write history includes a TransactWriteItems operation (Put or Update) permanently rejects all subsequent update operations — plain UpdateItem and transactional Update alike — with:
ValidationException: The update expression attempted to update the secondary index key to unsupported type
This happens even when:
- the update touches no index-related attribute (a
SET of a plain string attribute),
- the item carries no vector attribute and no search-schema attribute (plain string-only items),
- an identical item written via plain
PutItem updates without error,
- the stored wire representations of the transact-written and plain-written items are identical (verified via low-level
GetItem — same AttributeValue type tags on every attribute).
A plain PutItem overwrite does not restore updatability. Only DeleteItem + recreate does. PutItem, DeleteItem, GetItem, Query, and SearchVectors all continue to work normally throughout.
Environment: verified 2026-08-11, us-west-2, AWS SDK for JavaScript v3 (3.1105.0), reproduces deterministically on a freshly created table.
Expected behavior
Per the TransactWriteItems documentation, this ValidationException is a cancellation reason for genuine index-key type violations — none of these updates touch any index key. The vector search documentation documents no transactional limitation, and the launch blog explicitly describes adding embeddings via UpdateItem. All updates below should succeed.
Findings summary
Verified output of the self-contained reproduction script (full script below):
OK A1: PutItem 'plain-born'
OK A2: UpdateItem 'plain-born'
OK A3: UpdateItem 'plain-born' again (updates do not poison)
OK B1: TransactWriteItems Put 'transact-born' (identical content)
FAIL (ValidationException) B2: UpdateItem 'transact-born'
FAIL (TransactionCanceledException) B3: transactional Update 'transact-born'
OK C1: transactional Update 'plain-born' (first transact touch)
FAIL (ValidationException) C2: UpdateItem 'plain-born' after transact touch
OK D1: PutItem overwrite 'transact-born'
FAIL (ValidationException) D2: UpdateItem 'transact-born' after Put overwrite
OK E1: DeleteItem 'transact-born'
OK E2: PutItem recreate 'transact-born'
OK E3: UpdateItem 'transact-born' after delete + recreate
OK F1: PutItem 'with-vector' (8-dim vector + HASH attr)
OK F2: UpdateItem 'with-vector' (plain-born vector item updates fine)
Reading of the matrix:
- B: an item born via
TransactWriteItems rejects every update, plain or transactional.
- C: one transactional
Update on a healthy item poisons it for all subsequent updates.
- D: a plain
PutItem overwrite does not heal the item.
- E:
DeleteItem + recreate is the only way to restore updatability.
- F: the vector attribute itself is not the trigger — a plain-put item carrying a vector and the index
HASH attribute updates fine. The trigger is purely whether TransactWriteItems ever wrote the item.
The affected state is invisible in the item: fetching a poisoned item and a healthy clone through the low-level client shows byte-identical representations (all S strings; vector as L of N).
Impact: any application that uses DynamoDB transactions for multi-item consistency cannot use vector indexes — every transactionally written item becomes read-only for updates (the classic "accidentally read-only items" symptom, but without any type mismatch).
Workaround until fixed: none practical for transactional writers; delete + recreate per affected item, or avoid TransactWriteItems entirely on vector-indexed tables.
Reproduction (self-contained)
Node 18+, @aws-sdk/client-dynamodb + @aws-sdk/lib-dynamodb (3.1105.0). Creates its own throwaway on-demand table and deletes it. Runs in ~1 minute.
/**
* Reproduction: TransactWriteItems permanently breaks UpdateItem on items
* in tables that have a vector index.
*
* On any table with a vector index, an item whose write history includes a
* TransactWriteItems operation (Put or Update) rejects ALL subsequent
* update operations (plain UpdateItem and transactional Update) with:
*
* ValidationException: The update expression attempted to update the
* secondary index key to unsupported type
*
* This occurs even when:
* - the update touches no index-related attribute (SET of a plain string),
* - the item carries no vector attribute and no search-schema attribute,
* - an identical item written via plain PutItem updates without error,
* - the stored wire representations of the two items are identical
* (verified separately via low-level GetItem).
*
* The item is not healed by a plain PutItem overwrite; only DeleteItem +
* recreate restores updatability. DeleteItem and PutItem always work.
*
* Requirements: Node 18+, AWS credentials, @aws-sdk/client-dynamodb and
* @aws-sdk/lib-dynamodb (any version supporting VectorIndexes, e.g. 3.1105.0).
* The script creates its own throwaway table and deletes it when done.
*
* Run: node vector-index-transact-repro.mjs (region: env AWS_REGION or us-west-2)
*/
import {
DynamoDBClient,
CreateTableCommand,
DeleteTableCommand,
DescribeTableCommand
} from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
PutCommand,
DeleteCommand,
UpdateCommand,
TransactWriteCommand
} from "@aws-sdk/lib-dynamodb";
const REGION = process.env.AWS_REGION ?? "us-west-2";
const TABLE = `vector-transact-repro-${Date.now()}`;
const raw = new DynamoDBClient({ region: REGION });
const doc = DynamoDBDocumentClient.from(raw);
const results = [];
async function step(label, expected, fn) {
try {
await fn();
results.push({ label, expected, actual: "OK" });
console.log(`OK ${label}`);
} catch (e) {
results.push({ label, expected, actual: `FAIL (${e.name})` });
console.log(`FAIL ${label}\n ${e.name}: ${e.message}`);
}
}
const plainUpdate = pk =>
doc.send(
new UpdateCommand({
TableName: TABLE,
Key: { PK: pk, SK: "A" },
UpdateExpression: "SET #d = :d",
ExpressionAttributeNames: { "#d": "Data" },
ExpressionAttributeValues: { ":d": `updated-${Date.now()}` }
})
);
const transactUpdate = pk =>
doc.send(
new TransactWriteCommand({
TransactItems: [
{
Update: {
TableName: TABLE,
Key: { PK: pk, SK: "A" },
UpdateExpression: "SET #d = :d",
ExpressionAttributeNames: { "#d": "Data" },
ExpressionAttributeValues: { ":d": `t-updated-${Date.now()}` }
}
}
]
})
);
// Plain string-only item: no vector attribute, no search-schema attribute
const item = pk => ({ PK: pk, SK: "A", Data: "hello" });
console.log(`Creating ${TABLE} (minimal 8-dimension vector index)...`);
await raw.send(
new CreateTableCommand({
TableName: TABLE,
BillingMode: "PAY_PER_REQUEST",
AttributeDefinitions: [
{ AttributeName: "PK", AttributeType: "S" },
{ AttributeName: "SK", AttributeType: "S" },
{ AttributeName: "TenantId", AttributeType: "S" }
],
KeySchema: [
{ AttributeName: "PK", KeyType: "HASH" },
{ AttributeName: "SK", KeyType: "RANGE" }
],
VectorIndexes: [
{
IndexName: "ReproIndex",
VectorAttribute: { AttributeName: "Embedding" },
Dimensions: 8,
DistanceFunction: "COSINE",
Projection: { ProjectionType: "ALL" },
SearchSchema: [
{ AttributeName: "TenantId", SearchSchemaElementType: "HASH" }
]
}
]
})
);
for (;;) {
const d = await raw.send(new DescribeTableCommand({ TableName: TABLE }));
const active =
d.Table.TableStatus === "ACTIVE" &&
(d.Table.VectorIndexes ?? []).every(
i => (i.IndexStatus ?? "ACTIVE") === "ACTIVE"
);
if (active) break;
await new Promise(r => setTimeout(r, 3000));
}
console.log("Table and vector index ACTIVE.\n");
// ---- A. Baseline: plain-put item updates fine -----------------------------
await step("A1: PutItem 'plain-born'", "OK", () =>
doc.send(new PutCommand({ TableName: TABLE, Item: item("plain-born") }))
);
await step("A2: UpdateItem 'plain-born'", "OK", () => plainUpdate("plain-born"));
await step("A3: UpdateItem 'plain-born' again (updates do not poison)", "OK", () =>
plainUpdate("plain-born")
);
// ---- B. Transact-born item rejects all updates ----------------------------
await step("B1: TransactWriteItems Put 'transact-born' (identical content)", "OK", () =>
doc.send(
new TransactWriteCommand({
TransactItems: [{ Put: { TableName: TABLE, Item: item("transact-born") } }]
})
)
);
await step("B2: UpdateItem 'transact-born'", "OK expected — FAILS", () =>
plainUpdate("transact-born")
);
await step("B3: transactional Update 'transact-born'", "OK expected — FAILS", () =>
transactUpdate("transact-born")
);
// ---- C. A transactional update poisons a healthy item ---------------------
await step("C1: transactional Update 'plain-born' (first transact touch)", "OK", () =>
transactUpdate("plain-born")
);
await step("C2: UpdateItem 'plain-born' after transact touch", "OK expected — FAILS", () =>
plainUpdate("plain-born")
);
// ---- D. Plain PutItem overwrite does NOT heal -----------------------------
await step("D1: PutItem overwrite 'transact-born'", "OK", () =>
doc.send(new PutCommand({ TableName: TABLE, Item: item("transact-born") }))
);
await step("D2: UpdateItem 'transact-born' after Put overwrite", "OK expected — STILL FAILS", () =>
plainUpdate("transact-born")
);
// ---- E. DeleteItem + recreate heals ---------------------------------------
await step("E1: DeleteItem 'transact-born'", "OK", () =>
doc.send(new DeleteCommand({ TableName: TABLE, Key: { PK: "transact-born", SK: "A" } }))
);
await step("E2: PutItem recreate 'transact-born'", "OK", () =>
doc.send(new PutCommand({ TableName: TABLE, Item: item("transact-born") }))
);
await step("E3: UpdateItem 'transact-born' after delete + recreate", "OK", () =>
plainUpdate("transact-born")
);
// ---- F. Control: the vector attribute itself is not the trigger -----------
await step("F1: PutItem 'with-vector' (8-dim vector + HASH attr)", "OK", () =>
doc.send(
new PutCommand({
TableName: TABLE,
Item: {
PK: "with-vector",
SK: "A",
Data: "hello",
TenantId: "t1",
Embedding: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]
}
})
)
);
await step("F2: UpdateItem 'with-vector' (plain-born vector item updates fine)", "OK", () =>
plainUpdate("with-vector")
);
await raw.send(new DeleteTableCommand({ TableName: TABLE }));
console.log(`\n${TABLE} deleted.\n`);
console.log("================ SUMMARY ================");
for (const r of results) {
console.log(`${r.actual.padEnd(24)} ${r.label}`);
}
console.log(
"\nExpected on a correct service: every step above succeeds. Observed:\n" +
"any item whose write history includes TransactWriteItems (B2, B3, C2, D2)\n" +
"rejects updates with the secondary-index-key ValidationException, until\n" +
"deleted and recreated (E3)."
);
this was really interesting because the issue seems to come from the item's write history rather than the actual data type. I found it especially surprising that an item written with TransactWriteItems can become unable to update, even when the update does not touch the vector index. The fact that DeleteItem and recreate is the only way to restore updates also shows how serious this issue could be for applications that depend on transactions.
Some more info since posting, from a re-run with added controls:
The poison is per-item, not per-table or per-instant — my script didn't show this, as plain-born predated B1. A control alive throughout but never named in a transaction still updates fine after a transactional Update poisons a different item, and after 3 more. Same-partition items too.
BatchWriteItem does not reproduce it — such an item updates normally afterwards. The trigger is the TransactWriteItems path specifically.
The error is raised after condition evaluation, not at parse: on a poisoned item, an UpdateItem whose ConditionExpression fails returns ConditionalCheckFailedException, not ValidationException — yet a malformed expression returns ValidationException even with that same failing condition (a passing condition still returns it). Real parse errors precede the condition check; this one does not. It fires when the write proceeds, not at request validation.
It persists long term (observed 12h+) and hits real index participants: rows written transactionally with a 1024-dim vector and every search-schema attribute still rejected UpdateItem 11.8h later.
It survives PutItem but not DeleteItem — overwrite doesn't heal, delete + recreate does.
Would the team like request IDs for the failing calls, or the account/table ARN? Happy to send those privately.