UpdateExpression は、DynamoDB の update_item(CLI では update-item)で「どの属性をどう変えるか」を書く式です。 SET(値を入れる)、REMOVE(属性を消す)、ADD(数値に足す・セットに加える)、DELETE(セットから取り除く)の4つの句を組み合わせて書きます。

この記事では、4つの句の違いを早見表で整理し、それぞれの更新前後のアイテムを boto3 と AWS CLI の両方で示します。関数(if_not_exists・list_append)、ReturnValues の5種類、条件式と楽観ロック、よく出るエラーの文面と直し方まで、すべて実行結果つきで載せています。

確認環境:Ubuntu 24.04、Python 3.11.15、boto3 1.43.103、AWS CLI 1.46.1。AWS の API をローカルで再現するツール(moto 5.2.3)で実行しています。掲載するコードとコマンドは、実際の AWS でそのまま使える形(endpoint の指定なし)で書いています。

SET・REMOVE・ADD・DELETEの早見表

句 すること 対象の型 属性がないとき 典型的な用途
SET 値を入れる(上書き・新規追加) すべての型 新しく作られる。ただし price = price + :n のように自分自身を参照する式はエラー 値の更新、加算・減算、リストへの追加、ネストの更新
REMOVE 属性やリストの要素を消す すべての型 何も起きない(エラーにならない) 不要になった属性の削除
ADD 数値に足す/セットに要素を加える 数値、セット(SS・NS・BS) 数値:渡した値で作られる。セット:渡したセットで作られる カウンタ、タグの追加
DELETE セットから要素を取り除く セットのみ 何も起きない タグの削除

書き方の形は次のとおりです。

句 書き方 例
SET SET パス = 値, パス = 値 SET price = :p, spec.color = :c
REMOVE REMOVE パス, パス REMOVE note, history[0]
ADD ADD パス 値, パス 値(= は書かない) ADD stock :n, tags :t
DELETE DELETE パス 値(= は書かない) DELETE tags :t

SET で使える関数と演算です。

書き方 意味
SET a = :v 値を入れる
SET a = a + :n / a - :n 今の値に足す/引く
SET a = if_not_exists(a, :v) a がないときだけ :v を入れる
SET a = if_not_exists(a, :zero) + :n ないときは 0 から数える加算カウンタ
SET a = list_append(a, :list) リストの末尾に追加
SET a = list_append(:list, a) リストの先頭に追加
SET a.b = :v / a[0] = :v マップの中の属性/リストの要素を更新

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「DynamoDB UpdateExpressionの書き方|SET・ADD・条件」

準備:テーブルと共通のコード

以降の例は、パーティションキーが id(文字列)の Items テーブルと、次の共通コード(common.py)を使います。毎回同じ初期データに戻してから更新するので、結果をそのまま見比べられます。

import json
from decimal import Decimal

import boto3

dynamodb = boto3.resource("dynamodb", region_name="ap-northeast-1")
TABLE = "Items"


def create_table():
    client = dynamodb.meta.client
    if TABLE in client.list_tables()["TableNames"]:
        dynamodb.Table(TABLE).delete()
        client.get_waiter("table_not_exists").wait(TableName=TABLE)
    dynamodb.create_table(
        TableName=TABLE,
        KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}],
        AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}],
        BillingMode="PAY_PER_REQUEST",
    )
    client.get_waiter("table_exists").wait(TableName=TABLE)
    return dynamodb.Table(TABLE)


def reset(table):
    """毎回同じ初期データに戻す"""
    table.put_item(Item={
        "id": "item-1",
        "name": "desk",
        "price": 12000,
        "stock": 5,
        "tags": {"wood", "office"},          # 文字列セット(SS)
        "history": ["created"],              # リスト(L)
        "spec": {"color": "brown", "size": {"w": 120, "d": 60}},  # マップ(M)
        "note": "temp",
        "version": 1,
    })


def _plain(o):
    if isinstance(o, Decimal):
        return int(o) if o == o.to_integral_value() else float(o)
    if isinstance(o, set):
        return {"(set)": sorted(int(x) if isinstance(x, Decimal) else x for x in o)}
    raise TypeError(type(o))


def show(label, obj):
    print(label, json.dumps(obj, default=_plain, ensure_ascii=False, sort_keys=True))


def get(table, key="item-1"):
    return table.get_item(Key={"id": key}).get("Item")

boto3 の resource(Table)を使うと、Python の値(文字列・数値・set・list・dict)をそのまま渡せます。表示では、セットを {"(set)": [...]} と表しています。

:name のようにコロンで始まるものは値のプレースホルダーで、ExpressionAttributeValues で中身を渡します。#n のようにシャープで始まるものは属性名のプレースホルダーで、ExpressionAttributeNames で実際の名前を渡します。

SET:値を入れる・足す・リストに追加する

from common import create_table, reset, show, get

table = create_table()
reset(table)
show("更新前:", get(table))

# 1. 値を上書きする/新しい属性を足す(name は予約語なので #n に置き換える)
table.update_item(
    Key={"id": "item-1"},
    UpdateExpression="SET #n = :name, category = :cat",
    ExpressionAttributeNames={"#n": "name"},
    ExpressionAttributeValues={":name": "standing desk", ":cat": "furniture"},
)
show("1 上書きと追加:", get(table))

# 2. 今の値に足す・引く(加算カウンタ)
table.update_item(
    Key={"id": "item-1"},
    UpdateExpression="SET price = price + :up, stock = stock - :sold",
    ExpressionAttributeValues={":up": 500, ":sold": 2},
)
show("2 加算と減算:", {k: get(table)[k] for k in ("price", "stock")})

# 3. if_not_exists:属性がないときだけ初期値を入れる
for i in (1, 2):
    table.update_item(
        Key={"id": "item-1"},
        UpdateExpression="SET created_by = if_not_exists(created_by, :u), view_count = if_not_exists(view_count, :zero) + :one",
        ExpressionAttributeValues={":u": f"user-{i}", ":zero": 0, ":one": 1},
    )
    show(f"3 if_not_exists {i}回目:", {k: get(table)[k] for k in ("created_by", "view_count")})

# 4. list_append:リストの末尾・先頭に足す
table.update_item(
    Key={"id": "item-1"},
    UpdateExpression="SET history = list_append(history, :tail)",
    ExpressionAttributeValues={":tail": ["priced", "stocked"]},
)
show("4 末尾に追加:", get(table)["history"])
table.update_item(
    Key={"id": "item-1"},
    UpdateExpression="SET history = list_append(:head, history)",
    ExpressionAttributeValues={":head": ["draft"]},
)
show("4 先頭に追加:", get(table)["history"])

# 5. リストがまだないかもしれないとき:if_not_exists と組み合わせる
table.update_item(
    Key={"id": "item-1"},
    UpdateExpression="SET comments = list_append(if_not_exists(comments, :empty), :c)",
    ExpressionAttributeValues={":empty": [], ":c": ["first comment"]},
)
show("5 ないリストに追加:", get(table)["comments"])

# 6. ネストした属性・リストの要素を更新する
table.update_item(
    Key={"id": "item-1"},
    UpdateExpression="SET spec.color = :c, spec.#s.h = :h, history[0] = :first",
    ExpressionAttributeNames={"#s": "size"},
    ExpressionAttributeValues={":c": "white", ":h": 72, ":first": "planned"},
)
item = get(table)
show("6 ネストの更新:", {"spec": item["spec"], "history": item["history"]})
更新前: {"history": ["created"], "id": "item-1", "name": "desk", "note": "temp", "price": 12000, "spec": {"color": "brown", "size": {"d": 60, "w": 120}}, "stock": 5, "tags": {"(set)": ["office", "wood"]}, "version": 1}
1 上書きと追加: {"category": "furniture", "history": ["created"], "id": "item-1", "name": "standing desk", "note": "temp", "price": 12000, "spec": {"color": "brown", "size": {"d": 60, "w": 120}}, "stock": 5, "tags": {"(set)": ["office", "wood"]}, "version": 1}
2 加算と減算: {"price": 12500, "stock": 3}
3 if_not_exists 1回目: {"created_by": "user-1", "view_count": 1}
3 if_not_exists 2回目: {"created_by": "user-1", "view_count": 2}
4 末尾に追加: ["created", "priced", "stocked"]
4 先頭に追加: ["draft", "created", "priced", "stocked"]
5 ないリストに追加: ["first comment"]
6 ネストの更新: {"history": ["planned", "created", "priced", "stocked"], "spec": {"color": "white", "size": {"d": 60, "h": 72, "w": 120}}}

ポイントは次のとおりです。

  • SET は属性があれば上書き、なければ追加します(1)
  • price = price + :up のように、今の値を使った計算ができます(2)。読み出してから書き戻す必要がなく、1回のリクエストで済みます
  • if_not_exists(属性, 初期値) は、属性があればその値、なければ初期値になります。created_by は2回目も user-1 のまま、view_count は「なければ 0」から数え始めて 1 → 2 と増えています(3)
  • list_append は引数の順番で、末尾に足すか先頭に足すかが決まります(4)。追加する側は、要素が1つでもリストで渡します
  • spec.color のようにドットでマップの中、history[0] のように添字でリストの要素を指定できます(6)。size は予約語なので #s に置き換えています

REMOVE・ADD・DELETE と、4つをまとめた更新

from common import create_table, reset, show, get

table = create_table()
reset(table)
show("更新前:", get(table))

# --- REMOVE:属性やリストの要素を消す ---
table.update_item(
    Key={"id": "item-1"},
    UpdateExpression="REMOVE note, spec.#s.d, no_such_attr",   # ない属性を指定してもエラーにならない
    ExpressionAttributeNames={"#s": "size"},
)
show("REMOVE 後:", get(table))

table.update_item(Key={"id": "item-1"}, UpdateExpression="SET history = :h",
                  ExpressionAttributeValues={":h": ["a", "b", "c", "d"]})
table.update_item(Key={"id": "item-1"}, UpdateExpression="REMOVE history[1], history[2]")
show("REMOVE history[1], history[2] 後:", get(table)["history"])

# --- ADD:数値に足す/セットに要素を足す ---
table.update_item(
    Key={"id": "item-1"},
    UpdateExpression="ADD stock :n, sold_count :n, tags :t, colors :c",
    ExpressionAttributeValues={":n": 3, ":t": {"sale", "wood"}, ":c": {"white"}},
)
item = get(table)
show("ADD 後:", {k: item[k] for k in ("stock", "sold_count", "tags", "colors")})

table.update_item(Key={"id": "item-1"}, UpdateExpression="ADD stock :n",
                  ExpressionAttributeValues={":n": -10})
show("ADD に負の数:", {"stock": get(table)["stock"]})

# --- DELETE:セットから要素を取り除く ---
table.update_item(
    Key={"id": "item-1"},
    UpdateExpression="DELETE tags :t",
    ExpressionAttributeValues={":t": {"sale", "no-such-tag"}},   # ない要素は無視される
)
show("DELETE 後:", {"tags": get(table)["tags"]})

table.update_item(Key={"id": "item-1"}, UpdateExpression="DELETE colors :c",
                  ExpressionAttributeValues={":c": {"white"}})
show("最後の1要素を DELETE:", {"colors があるか": "colors" in get(table)})

# --- 4つを1回の更新にまとめる ---
table.update_item(
    Key={"id": "item-1"},
    UpdateExpression="SET price = :p, updated_by = :u REMOVE history ADD version :one DELETE tags :t",
    ExpressionAttributeValues={":p": 9800, ":u": "admin", ":one": 1, ":t": {"office"}},
)
show("まとめて更新:", get(table))
更新前: {"history": ["created"], "id": "item-1", "name": "desk", "note": "temp", "price": 12000, "spec": {"color": "brown", "size": {"d": 60, "w": 120}}, "stock": 5, "tags": {"(set)": ["office", "wood"]}, "version": 1}
REMOVE 後: {"history": ["created"], "id": "item-1", "name": "desk", "price": 12000, "spec": {"color": "brown", "size": {"w": 120}}, "stock": 5, "tags": {"(set)": ["office", "wood"]}, "version": 1}
REMOVE history[1], history[2] 後: ["a", "d"]
ADD 後: {"colors": {"(set)": ["white"]}, "sold_count": 3, "stock": 8, "tags": {"(set)": ["office", "sale", "wood"]}}
ADD に負の数: {"stock": -2}
DELETE 後: {"tags": {"(set)": ["office", "wood"]}}
最後の1要素を DELETE: {"colors があるか": false}
まとめて更新: {"id": "item-1", "name": "desk", "price": 9800, "sold_count": 3, "spec": {"color": "brown", "size": {"w": 120}}, "stock": -2, "tags": {"(set)": ["wood"]}, "updated_by": "admin", "version": 2}
  • REMOVE は、ない属性を指定してもエラーになりません。リストの要素を消すと、残りの要素が前に詰まります。history[1] と history[2] は「更新前の位置」で解釈されるので、b と c が消えて a と d が残ります
  • ADD は、属性がなくても動きます。sold_count はなかったので 3 で作られ、colors はなかったので渡したセットで作られました。セットへの ADD は、すでにある要素(wood)を重複させません
  • ADD に負の数を渡すと引き算になります
  • DELETE は、セットにない要素を指定しても無視されます。最後の1要素を取り除くと、属性そのものがなくなります
  • 4つの句は1回の更新にまとめて書けます。句の間はスペースで区切り、同じ句の中はカンマで区切ります

ADD と SET の加算の使い分け

やりたいこと 書き方 属性がないとき
数値に足す(属性は必ずある) SET stock = stock + :n エラー
数値に足す(ないときは 0 から) SET stock = if_not_exists(stock, :zero) + :n :n になる
数値に足す(短く書く) ADD stock :n :n になる
セットに要素を足す ADD tags :t 渡したセットで作られる
リストに要素を足す SET history = list_append(history, :l) エラー(if_not_exists と組み合わせる)

DynamoDB の開発者ガイドには、ADD が対応するのは数値型とセット型だけであること、DELETE が対応するのはセット型だけであること、そして「一般に、ADD よりも SET を使うことをすすめる」ことが書かれています。数値の加算は SET と if_not_exists の組み合わせで書けるので、セットへの追加に ADD を使う、と分けると整理しやすくなります。

AWS CLIで同じ4つを書く

CLI では、値を {"S": "文字列"}、{"N": "数値"}、{"SS": [...]} のように型つきで渡します。

# 更新前のアイテムを入れる
aws dynamodb put-item --table-name Items --item '{
  "id":      {"S": "item-2"},
  "name":    {"S": "chair"},
  "price":   {"N": "8000"},
  "stock":   {"N": "10"},
  "tags":    {"SS": ["office", "wood"]},
  "note":    {"S": "temp"}
}'

# SET:名前を変え、価格に500を足す
aws dynamodb update-item --table-name Items --key '{"id":{"S":"item-2"}}' \
  --update-expression 'SET #n = :name, price = price + :up' \
  --expression-attribute-names '{"#n":"name"}' \
  --expression-attribute-values '{":name":{"S":"office chair"},":up":{"N":"500"}}'

# REMOVE:note を消す
aws dynamodb update-item --table-name Items --key '{"id":{"S":"item-2"}}' \
  --update-expression 'REMOVE note'

# ADD:在庫に5を足し、タグに sale を加える
aws dynamodb update-item --table-name Items --key '{"id":{"S":"item-2"}}' \
  --update-expression 'ADD stock :n, tags :t' \
  --expression-attribute-values '{":n":{"N":"5"},":t":{"SS":["sale"]}}'

# DELETE:タグから wood を取り除き、更新後の値を受け取る
aws dynamodb update-item --table-name Items --key '{"id":{"S":"item-2"}}' \
  --update-expression 'DELETE tags :t' \
  --expression-attribute-values '{":t":{"SS":["wood"]}}' \
  --return-values UPDATED_NEW --query Attributes

各コマンドのあとに get-item で取り出したアイテムです(1行にまとめて表示)。

# 更新前
{"id": {"S": "item-2"}, "name": {"S": "chair"}, "note": {"S": "temp"}, "price": {"N": "8000"}, "stock": {"N": "10"}, "tags": {"SS": ["office", "wood"]}}
# SET のあと
{"id": {"S": "item-2"}, "name": {"S": "office chair"}, "note": {"S": "temp"}, "price": {"N": "8500"}, "stock": {"N": "10"}, "tags": {"SS": ["office", "wood"]}}
# REMOVE のあと
{"id": {"S": "item-2"}, "name": {"S": "office chair"}, "price": {"N": "8500"}, "stock": {"N": "10"}, "tags": {"SS": ["office", "wood"]}}
# ADD のあと
{"id": {"S": "item-2"}, "name": {"S": "office chair"}, "price": {"N": "8500"}, "stock": {"N": "15"}, "tags": {"SS": ["office", "wood", "sale"]}}
# DELETE のあと
{"id": {"S": "item-2"}, "name": {"S": "office chair"}, "price": {"N": "8500"}, "stock": {"N": "15"}, "tags": {"SS": ["office", "sale"]}}

最後の DELETE のコマンド自体の出力(--return-values UPDATED_NEW)は次のとおりです。

{
    "tags": {
        "SS": [
            "office",
            "sale"
        ]
    }
}
項目 boto3(resource) AWS CLI
式 UpdateExpression="..." --update-expression '...'
属性名の置き換え ExpressionAttributeNames={"#n": "name"} --expression-attribute-names '{"#n":"name"}'
値 ExpressionAttributeValues={":up": 500} --expression-attribute-values '{":up":{"N":"500"}}'
条件 ConditionExpression="..." --condition-expression '...'
戻り値 ReturnValues="UPDATED_NEW" --return-values UPDATED_NEW

boto3 の client(boto3.client("dynamodb"))を使う場合は、値の書き方が CLI と同じ型つきの形になります。

ReturnValues:5種類の戻り値の違い

update_item は、既定では更新結果を返しません。ReturnValues を指定すると、更新前または更新後の属性を、同じリクエストの応答で受け取れます。 同じ更新(price に加算、category を追加、note を削除)を5種類で実行した結果です。

from common import create_table, reset, show

table = create_table()
for rv in ("NONE", "ALL_OLD", "UPDATED_OLD", "ALL_NEW", "UPDATED_NEW"):
    reset(table)
    res = table.update_item(
        Key={"id": "item-1"},
        UpdateExpression="SET price = price + :up, category = :cat REMOVE note",
        ExpressionAttributeValues={":up": 500, ":cat": "furniture"},
        ReturnValues=rv,
    )
    show(f"{rv:<12}:", res.get("Attributes", "(Attributes キーなし)"))
NONE        : "(Attributes キーなし)"
ALL_OLD     : {"history": ["created"], "id": "item-1", "name": "desk", "note": "temp", "price": 12000, "spec": {"color": "brown", "size": {"d": 60, "w": 120}}, "stock": 5, "tags": {"(set)": ["office", "wood"]}, "version": 1}
UPDATED_OLD : {"note": "temp", "price": 12000}
ALL_NEW     : {"category": "furniture", "history": ["created"], "id": "item-1", "name": "desk", "price": 12500, "spec": {"color": "brown", "size": {"d": 60, "w": 120}}, "stock": 5, "tags": {"(set)": ["office", "wood"]}, "version": 1}
UPDATED_NEW : {"category": "furniture", "price": 12500}
ReturnValues 返るもの 使いどころ
NONE(既定) 何も返らない(Attributes キーがない) 結果が要らないとき
ALL_OLD 更新前のアイテム全体 変更前の状態を記録に残す
UPDATED_OLD 更新した属性だけの、更新前の値 何から変わったかを知る
ALL_NEW 更新後のアイテム全体 更新後の最新のアイテムをそのまま使う
UPDATED_NEW 更新した属性だけの、更新後の値 カウンタの新しい値を受け取る

REMOVE した note は、UPDATED_OLD には入り、UPDATED_NEW には入りません(更新後には存在しないため)。逆に、新しく追加した category は UPDATED_NEW にだけ入ります。

API リファレンスによると、戻り値を要求しても読み取りキャパシティユニットは消費されず、返る値は強い整合性のあるものです。加算したあとの値が必要なときは、get_item で読み直すのではなく UPDATED_NEW で受け取ると、1回のリクエストで済みます。

条件つき更新(ConditionExpression)

条件なしの update_item は「なければ作る」

API リファレンスに「UpdateItem は既存アイテムの属性を編集し、アイテムがなければ新しく追加する」とあるとおり、存在しないキーを指定すると新しいアイテムが作られます。既存のアイテムだけを更新したいときは、ConditionExpression に attribute_exists(キー属性) を付けます。

from botocore.exceptions import ClientError

from common import create_table, reset, show, get, dynamodb

table = create_table()
reset(table)

# 1. 条件なし:存在しないキーを更新すると、新しいアイテムが作られる
table.update_item(Key={"id": "item-999"}, UpdateExpression="SET stock = :s",
                  ExpressionAttributeValues={":s": 1})
show("1 条件なし:", get(table, "item-999"))
table.delete_item(Key={"id": "item-999"})

# 2. attribute_exists:あるアイテムだけ更新する
try:
    table.update_item(
        Key={"id": "item-999"},
        UpdateExpression="SET stock = :s",
        ConditionExpression="attribute_exists(id)",
        ExpressionAttributeValues={":s": 1},
    )
except ClientError as e:
    print("2 attribute_exists:", e.response["Error"]["Code"], "/", e.response["Error"]["Message"])
show("2 作られていない:", get(table, "item-999"))

# 3. 在庫が足りるときだけ減らす
def sell(qty):
    try:
        res = table.update_item(
            Key={"id": "item-1"},
            UpdateExpression="SET stock = stock - :q",
            ConditionExpression="stock >= :q",
            ExpressionAttributeValues={":q": qty},
            ReturnValues="UPDATED_NEW",
        )
        return f"{qty}個 → 残り {res['Attributes']['stock']}"
    except dynamodb.meta.client.exceptions.ConditionalCheckFailedException:
        return f"{qty}個 → 在庫が足りないため更新しない"

print("3", sell(3))
print("3", sell(3))

# 4. 条件に合わなかったときの今の値を受け取る
try:
    table.update_item(
        Key={"id": "item-1"},
        UpdateExpression="SET stock = stock - :q",
        ConditionExpression="stock >= :q",
        ExpressionAttributeValues={":q": 3},
        ReturnValuesOnConditionCheckFailure="ALL_OLD",
    )
except ClientError as e:
    print("4 Code:", e.response["Error"]["Code"])
    print("4 Item:", e.response.get("Item"))

# 5. 条件式で使える主な書き方
checks = {
    "attribute_not_exists(deleted_at)": {},
    "begins_with(#n, :p)": {"names": {"#n": "name"}, "values": {":p": "de"}},
    "contains(tags, :t)": {"values": {":t": "wood"}},
    "price BETWEEN :lo AND :hi": {"values": {":lo": 10000, ":hi": 20000}},
    "size(history) < :max": {"values": {":max": 5}},
    "attribute_type(spec, :m)": {"values": {":m": "M"}},
    "stock > :z AND NOT contains(tags, :t)": {"values": {":z": 0, ":t": "wood"}},
}
for cond, opt in checks.items():
    kwargs = dict(Key={"id": "item-1"}, UpdateExpression="SET checked = :c", ConditionExpression=cond,
                  ExpressionAttributeValues={":c": True, **opt.get("values", {})})
    if "names" in opt:
        kwargs["ExpressionAttributeNames"] = opt["names"]
    try:
        table.update_item(**kwargs)
        print(f"5 {cond:<40} → 更新された")
    except ClientError as e:
        print(f"5 {cond:<40} → {e.response['Error']['Code']}")
1 条件なし: {"id": "item-999", "stock": 1}
2 attribute_exists: ConditionalCheckFailedException / The conditional request failed
2 作られていない: null
3 3個 → 残り 2
3 3個 → 在庫が足りないため更新しない
4 Code: ConditionalCheckFailedException
4 Item: {'id': {'S': 'item-1'}, 'name': {'S': 'desk'}, 'price': {'N': '12000'}, 'stock': {'N': '2'}, 'tags': {'SS': ['wood', 'office']}, 'history': {'L': [{'S': 'created'}]}, 'spec': {'M': {'color': {'S': 'brown'}, 'size': {'M': {'w': {'N': '120'}, 'd': {'N': '60'}}}}}, 'note': {'S': 'temp'}, 'version': {'N': '1'}}
5 attribute_not_exists(deleted_at)         → 更新された
5 begins_with(#n, :p)                      → 更新された
5 contains(tags, :t)                       → 更新された
5 price BETWEEN :lo AND :hi                → 更新された
5 size(history) < :max                     → 更新された
5 attribute_type(spec, :m)                 → 更新された
5 stock > :z AND NOT contains(tags, :t)    → ConditionalCheckFailedException
  • 条件に合わないと ConditionalCheckFailedException になり、アイテムは変更されません(2)
  • 「在庫が足りるときだけ減らす」は stock >= :q を条件にします(3)。確認と更新が1回のリクエストで行われるので、同時に複数の更新が来ても在庫がマイナスになりません
  • ReturnValuesOnConditionCheckFailure="ALL_OLD" を付けると、条件に合わなかったときの今のアイテムが例外の response["Item"] に入ります(4)。型つきの形({"N": "2"} など)で返る点に注意してください
  • 例外は、ClientError の Code で見分ける方法と、client.exceptions.ConditionalCheckFailedException で受ける方法のどちらでも書けます

条件式で使える書き方

書き方 意味
attribute_exists(a) 属性 a がある
attribute_not_exists(a) 属性 a がない
a = :v、a <> :v、a < :v、a >= :v 比較
a BETWEEN :lo AND :hi 範囲
a IN (:x, :y) いずれかと等しい
begins_with(a, :prefix) 文字列が :prefix で始まる
contains(a, :v) 文字列に含む/セット・リストに要素がある
size(a) < :n 文字列の長さ、リスト・セットの要素数
attribute_type(a, :t) 型が :t(S、N、M、L、SS など)
条件 AND 条件、条件 OR 条件、NOT 条件 組み合わせ

CLI での条件つき更新

aws dynamodb update-item --table-name Items --key '{"id":{"S":"item-2"}}' \
  --update-expression 'SET stock = stock - :q' \
  --condition-expression 'stock >= :q' \
  --expression-attribute-values '{":q":{"N":"100"}}'
An error occurred (ConditionalCheckFailedException) when calling the UpdateItem operation: The conditional request failed

終了コードは255です。シェルスクリプトでは、終了コードで「条件に合わなかった」ことを判定できます。

楽観ロック:version 属性で更新の衝突を防ぐ

2人が同じアイテムを読み、それぞれ書き戻すと、後から書いたほうが先の更新を上書きしてしまいます。アイテムに version 属性を持たせ、「読んだときの version と同じなら更新し、同時に version を1つ進める」と書くと、先に誰かが更新していた場合に気づけます。 これが楽観ロックです。

from botocore.exceptions import ClientError

from common import create_table, reset, show, get

table = create_table()
reset(table)


def update_price(item, new_price, who):
    """読んだときの version と同じなら更新し、version を1つ進める"""
    try:
        table.update_item(
            Key={"id": item["id"]},
            UpdateExpression="SET price = :p, updated_by = :w, version = version + :one",
            ConditionExpression="version = :expected",
            ExpressionAttributeValues={
                ":p": new_price, ":w": who, ":one": 1,
                ":expected": item["version"],
            },
        )
        return True
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return False
        raise


# AさんとBさんが、同じ時点のアイテム(version=1)を読む
a = get(table)
b = get(table)
print("A の更新:", update_price(a, 13000, "A"))
print("B の更新:", update_price(b, 11000, "B"), "(A が先に更新したので version が合わない)")
show("この時点:", {k: get(table)[k] for k in ("price", "updated_by", "version")})

# B は読み直してからやり直す
for attempt in range(1, 4):
    b = get(table)
    if update_price(b, 11000, "B"):
        print(f"B の再試行 {attempt} 回目: True")
        break
show("最終結果:", {k: get(table)[k] for k in ("price", "updated_by", "version")})
A の更新: True
B の更新: False (A が先に更新したので version が合わない)
この時点: {"price": 13000, "updated_by": "A", "version": 2}
B の再試行 1 回目: True
最終結果: {"price": 11000, "updated_by": "B", "version": 3}
sequenceDiagram
  participant A
  participant B
  participant D as DynamoDB
  A->>D: 読む(version=1)
  B->>D: 読む(version=1)
  A->>D: 更新(条件 version = 1)→ 成功、version=2
  B->>D: 更新(条件 version = 1)→ ConditionalCheckFailedException
  B->>D: 読み直す(version=2)
  B->>D: 更新(条件 version = 2)→ 成功、version=3

条件に合わなかったときは、読み直してから「やり直す」か「利用者に最新の内容を見せて判断してもらう」かを、扱うデータに合わせて決めます。

よく出るエラーと直し方

式の内容に関するエラー(ValidationException)

from botocore.exceptions import ClientError

from common import create_table, reset, show, get

table = create_table()
reset(table)


def attempt(label, **kwargs):
    try:
        table.update_item(Key={"id": "item-1"}, **kwargs)
        print(f"[{label}] 成功")
    except ClientError as e:
        err = e.response["Error"]
        print(f"[{label}] {err['Code']}: {err['Message']}")


# 1. 予約語をそのまま書く
attempt("予約語", UpdateExpression="SET name = :v", ExpressionAttributeValues={":v": "x"})
# 2. ない属性に加算する
attempt("ない属性に加算", UpdateExpression="SET points = points + :n", ExpressionAttributeValues={":n": 1})
# 3. 親のマップがないネストを更新する
attempt("親がないネスト", UpdateExpression="SET options.gift = :v", ExpressionAttributeValues={":v": True})
# 4. 文字列の属性に ADD する
attempt("文字列にADD", UpdateExpression="ADD note :n", ExpressionAttributeValues={":n": 1})
# 5. リストに DELETE する
attempt("リストにDELETE", UpdateExpression="DELETE history :s", ExpressionAttributeValues={":s": {"created"}})
# 6. 値のプレースホルダーを渡し忘れる
attempt("値の渡し忘れ", UpdateExpression="SET price = :p")
# 7. キー属性を更新する
attempt("キーの更新", UpdateExpression="SET id = :v", ExpressionAttributeValues={":v": "item-2"})
# 8. リストの範囲外の要素に SET する(エラーにならず末尾に入る)
attempt("範囲外の要素", UpdateExpression="SET history[10] = :v", ExpressionAttributeValues={":v": "far"})
show("  history:", get(table)["history"])
[予約語] ValidationException: Invalid UpdateExpression: Attribute name is a reserved keyword; reserved keyword: name
[ない属性に加算] ValidationException: The provided expression refers to an attribute that does not exist in the item
[親がないネスト] ValidationException: The document path provided in the update expression is invalid for update
[文字列にADD] ValidationException: An operand in the update expression has an incorrect data type
[リストにDELETE] ValidationException: An operand in the update expression has an incorrect data type
[値の渡し忘れ] ValidationException: Invalid UpdateExpression: An expression attribute value used in expression is not defined; attribute value: :p
[キーの更新] ValidationException: One or more parameter values were invalid: Cannot update attribute id. This attribute is part of the key
[範囲外の要素] 成功
  history: ["created", "far"]
場面 メッセージの要点 直し方
予約語 Attribute name is a reserved keyword 属性名を #n などに置き換え、ExpressionAttributeNames で渡す
ない属性に加算 refers to an attribute that does not exist in the item if_not_exists(属性, :zero) + :n にする
親がないネスト The document path ... is invalid for update 先に親のマップを作る(SET options = :m)か、if_not_exists で親ごと入れる
文字列に ADD/リストに DELETE An operand ... has an incorrect data type ADD は数値とセット、DELETE はセットにだけ使う。リストは list_append と REMOVE
値の渡し忘れ An expression attribute value used in expression is not defined ExpressionAttributeValues に :p を足す
キーの更新 Cannot update attribute id. This attribute is part of the key キーは変更できない。新しいキーで put し、元のアイテムを delete する

予約語は name のほかにも多く、status、size、count、views なども含まれます。 予約語かどうか迷う属性名は、最初から # で置き換えておくと確実です。なお、リストの範囲外の添字に SET した場合はエラーにならず、末尾に追加されます(8)。開発者ガイドにも、要素がまだなければ SET はリストの末尾に追加すると書かれています。

パスの重複(Two document paths overlap)と、句の書きすぎ

1回の更新の中で、同じ属性(または親と子)を2回書くことはできません。

from botocore.exceptions import ClientError

from common import create_table, reset

table = create_table()
reset(table)


def attempt(label, **kwargs):
    try:
        table.update_item(Key={"id": "item-1"}, **kwargs)
        print(f"[{label}] 成功")
    except ClientError as e:
        err = e.response["Error"]
        # メッセージのうち「Invalid UpdateExpression: 」より後ろを表示する
        detail = err["Message"].split("Invalid UpdateExpression: ")[-1]
        print(f"[{label}] {err['Code']}\n    {detail}")


# 1. 親と子を同時に更新する
attempt("親と子", UpdateExpression="SET spec = :m, spec.color = :c",
        ExpressionAttributeValues={":m": {"color": "red"}, ":c": "blue"})
# 2. 同じ属性を SET に2回書く
attempt("同じ属性を2回", UpdateExpression="SET price = :a, price = :b",
        ExpressionAttributeValues={":a": 1, ":b": 2})
# 3. 同じ属性を SET と REMOVE の両方に書く
attempt("SETとREMOVE", UpdateExpression="SET note = :a REMOVE note",
        ExpressionAttributeValues={":a": "x"})
# 4. SET を2回書く
attempt("SETを2回", UpdateExpression="SET price = :a SET stock = :b",
        ExpressionAttributeValues={":a": 1, ":b": 2})
# 直した形:親は1回だけ、SET は1つにまとめてカンマで区切る
attempt("直した形", UpdateExpression="SET spec.color = :c, price = :a, stock = :b",
        ExpressionAttributeValues={":c": "blue", ":a": 1, ":b": 2})
[親と子] ValidationException
    Two document paths overlap with each other; must remove or rewrite one of these paths; path one: [spec], path two: [spec, color]
[同じ属性を2回] ValidationException
    Two document paths overlap with each other; must remove or rewrite one of these paths; path one: [price], path two: [price]
[SETとREMOVE] ValidationException
    Two document paths overlap with each other; must remove or rewrite one of these paths; path one: [note], path two: [note]
[SETを2回] ValidationException
    The "SET" section can only be used once in an update expression;
[直した形] 成功
エラーになる書き方 理由 直した書き方
SET spec = :m, spec.color = :c 親(spec)と子(spec.color)を同時に更新している SET spec = :m だけにして、:m の中に color を入れる
SET price = :a, price = :b 同じ属性を2回書いている どちらか1つにする
SET note = :a REMOVE note 別の句でも、同じ属性は1回まで どちらか1つにする
SET price = :a SET stock = :b 同じ句(SET)は1回まで SET price = :a, stock = :b とカンマでつなぐ

開発者ガイドにも、各句はどの順番で書いてもよいが、それぞれのキーワードは1回だけ使える、とあります。

予約語のエラーを CLI で見る

aws dynamodb update-item --table-name Items --key '{"id":{"S":"item-2"}}' \
  --update-expression 'SET name = :name' \
  --expression-attribute-values '{":name":{"S":"x"}}'
An error occurred (ValidationException) when calling the UpdateItem operation: Invalid UpdateExpression: Attribute name is a reserved keyword; reserved keyword: name

やりたいこと別の逆引き表

やりたいこと UpdateExpression あわせて指定するもの
値を更新する SET price = :p
複数の属性を更新する SET price = :p, stock = :s
予約語の属性を更新する SET #n = :v ExpressionAttributeNames={"#n": "name"}
カウンタを1増やす(ないときは1から) SET cnt = if_not_exists(cnt, :zero) + :one ReturnValues="UPDATED_NEW"
最初の1回だけ値を入れる SET created_at = if_not_exists(created_at, :now)
リストの末尾に追加する SET logs = list_append(if_not_exists(logs, :empty), :l)
リストの先頭の要素を消す REMOVE logs[0]
マップの中の1つを更新する SET spec.color = :c
属性を消す REMOVE note
タグを足す ADD tags :t :t はセット
タグを外す DELETE tags :t :t はセット
あるアイテムだけ更新する (任意の式) ConditionExpression="attribute_exists(id)"
在庫が足りるときだけ減らす SET stock = stock - :q ConditionExpression="stock >= :q"
楽観ロック SET ..., version = version + :one ConditionExpression="version = :expected"

よくある質問

Q. SET と ADD のどちらで加算すればよいですか? A. どちらでも加算できます。開発者ガイドは、一般に ADD より SET をすすめています。属性がないときも動かしたい場合は SET cnt = if_not_exists(cnt, :zero) + :n と書きます。セットに要素を足すときは ADD を使います。

Q. REMOVE と DELETE の違いは何ですか? A. REMOVE は属性そのもの(またはリストの要素)を消します。DELETE はセット型の属性から、指定した要素だけを取り除きます。

Q. update_item で存在しないキーを指定するとどうなりますか? A. 新しいアイテムが作られます。既存のアイテムだけを更新したいときは、ConditionExpression="attribute_exists(キー属性)" を付けます。

Q. 更新後の値を知るために、もう一度 get_item する必要はありますか? A. ReturnValues="UPDATED_NEW"(または ALL_NEW)を付ければ、更新の応答で受け取れます。

まとめ

  • UpdateExpression は SET・REMOVE・ADD・DELETE の4つの句。句の間はスペース、句の中はカンマで区切り、同じ句は1回だけ書く
  • SET は上書きと追加、加算、list_append、ネストの更新まで担当する。属性がないかもしれないときは if_not_exists を組み合わせる
  • ADD は数値とセット、DELETE はセット専用。属性がなくても動く
  • ReturnValues で更新前後の値を同じ応答で受け取れる。カウンタには UPDATED_NEW が便利
  • 条件なしの update_item は「なければ作る」。既存だけを更新するなら attribute_exists を付ける
  • 条件に合わないと ConditionalCheckFailedException。version 属性と組み合わせると楽観ロックになる
  • 予約語は # で置き換え、同じ属性(親と子を含む)は1回の更新で1度だけ書く

参考・出典

  • Amazon DynamoDB 開発者ガイド「Using update expressions in DynamoDB」(docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.UpdateExpressions.html)
  • Amazon DynamoDB API Reference「UpdateItem」(docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateItem.html)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「DynamoDB UpdateExpressionの書き方|SET・ADD・条件」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?