DynamoDB TransactWriteItems in Python (boto3)

Transactions are one of the places where boto3's two APIs diverge hardest: transact_write_items exists only on the low-level client, so the native-Python-types convenience you get from Table is off the table here. And when the transaction fails, what you need is in a corner of the exception most boto3 code never looks at. (What a transaction buys you is the same in every SDK.)

Code

import boto3

client = boto3.client("dynamodb")

# Move one award between two songs — atomically. If the first song has no
# award to give, NEITHER update happens.
try:
    client.transact_write_items(
        TransactItems=[
            {
                "Update": {
                    "TableName": "Music",
                    "Key": {"Artist": {"S": "Arturo Sandoval"}, "SongTitle": {"S": "Cubano Chant"}},
                    "UpdateExpression": "SET #upd0 = #upd0 - :one",
                    "ConditionExpression": "#upd0 >= :one",
                    "ExpressionAttributeNames": {"#upd0": "Awards"},
                    "ExpressionAttributeValues": {":one": {"N": "1"}},
                }
            },
            {
                "Update": {
                    "TableName": "Music",
                    "Key": {"Artist": {"S": "Arturo Sandoval"}, "SongTitle": {"S": "A Mis Abuelos"}},
                    "UpdateExpression": "SET #upd0 = if_not_exists(#upd0, :zero) + :one",
                    "ExpressionAttributeNames": {"#upd0": "Awards"},
                    "ExpressionAttributeValues": {":one": {"N": "1"}, ":zero": {"N": "0"}},
                }
            },
        ]
    )
    print("Transaction committed")
except client.exceptions.TransactionCanceledException as e:
    # One reason per action, in TransactItems order. Code "None" means that
    # action was fine — some OTHER action sank the transaction.
    codes = [reason["Code"] for reason in e.response["CancellationReasons"]]
    print(f"Transaction canceled: {codes}")  # e.g. ['ConditionalCheckFailed', 'None']

Explanation

  • TransactItems — a list of Put, Update, Delete and ConditionCheck dicts, every value in DynamoDB JSON, no exceptions. This is the one boto3 call where the typed form is not optional, and it is why the section at the end of this page exists. The caps are on the CLI page.
  • CancellationReasons is not inside Error. botocore lifts modelled error fields to the top of the response dict, so the caught exception carries e.response with the keys CancellationReasons, Error, Message and ResponseMetadata side by side. Looking for it under e.response["Error"] finds nothing, and e.response["Error"] holds only the summary code and message.
  • No "Message" on the None entries — a successful action's reason is the single-key dict {"Code": "None"}, so the natural [r["Message"] for r in reasons] raises KeyError: 'Message' on exactly the actions that worked. Use r.get("Message").
  • A generated exception class — botocore builds client.exceptions.TransactionCanceledException from the service model at runtime, which is why it hangs off the client instance and why you cannot from botocore.exceptions import ... it. In a helper that does not have the client in scope, catch botocore.exceptions.ClientError and branch on e.response["Error"]["Code"]; the generated class is a subclass of it.
  • Structural mistakes do not arrive as cancellations, so the except clause in the snippet never sees them. Two actions aimed at the same item raise a bare ClientError whose code is ValidationException and whose e.response has no CancellationReasons key, since the transaction was rejected before any action ran. Catch ClientError at the outer edge if you want those logged with the same context.
  • ReturnValuesOnConditionCheckFailure: "ALL_OLD" on an action puts the losing item under an Item key in that action's reason, in DynamoDB JSON, saving you the follow-up get_item after you have already lost the race.
  • boto3 fills ClientRequestToken for you. Captured on the wire, two identical transact_write_items calls left with two different UUIDs, so the token covers a single call and not your own catch-and-retry loop. Pass a stable one yourself if the retry can outlive the process.
  • Retry on TransactionConflict, never on ConditionalCheckFailed — the first says someone else held the item for a moment; the second says your precondition is false and will still be false next time. Those are the only two codes most handlers need to separate, and the full set is decoded on the TransactionCanceledException page.
  • Cost — a transactional write bills about twice what the same write costs outside one, measured on the CLI page. If you only need atomicity on a single item, a conditional write buys it at half the price.

There is no resource-API version of this

boto3.resource("dynamodb").Table(...) has no transact_write_items attribute; only resource.meta.client does. So a codebase that has settled on Table and native Python types has to drop back to typed DynamoDB JSON for its transactions, or serialise by hand with boto3.dynamodb.types.TypeSerializer:

from boto3.dynamodb.types import TypeSerializer

serialize = TypeSerializer().serialize
values = {k: serialize(v) for k, v in {":one": 1, ":zero": 0}.items()}

TypeSerializer applies the same rules as the resource API, which means it rejects float and expects decimal.Decimal for anything fractional. The DynamoDB JSON converter does the same conversion in the browser when you just need to paste a literal into a script. To edit the items a transaction touches without writing either form by hand, download DynoTable.

References

Last verified 2026-07-28 against the official AWS documentation linked above.

Work with DynamoDB without the Console

A fast DynamoDB desktop client that runs the real SQL DynamoDB can’t — JOINs, GROUP BY, aggregates — with visual editing and an AI agent on your own Bedrock keys.

Free 30-day trial, no credit card — then the Free plan with no time limit.