DynamoDB PutItem vs UpdateItem: Update Item If Not Exists, Upsert, if_not_exists() and boto3 Examples (Every Error Explained)
PutItem vs UpdateItem in DynamoDB comes down to one sentence each. PutItem writes the whole item: if an item with that key exists, it is replaced completely, and every attribute you didn't send is gone. UpdateItem changes only the attributes you name, and if the item doesn't exist yet, it creates it. That second half is the surprise behind most "dynamodb update item if not exists" searches: UpdateItem is already an upsert. There is no separate upsert call because the update call is the upsert. What you usually need is the opposite control: attribute_exists(pk) to update only items that exist, attribute_not_exists(pk) on a put to create only new ones, and if_not_exists(attr, :value) to set one attribute without overwriting it. Both calls cost the same: DynamoDB bills each write on the larger of the item's size before and after, even when UpdateItem changes a single attribute. And one quiet trap deserves a warning up front: an UpdateItem that only removes an attribute, sent for a key that doesn't exist, creates a brand-new empty item.
Jake runs a phone repair shop, and his booking system writes every repair job to a DynamoDB table. One Monday a customer's phone number vanished from her booking. The night before, Jake's status script had saved the job as "repaired" with put_item, sending only the key and the new status. DynamoDB did exactly what it was told: it replaced the whole booking with those two fields. Then he switched to update_item, and a week later found dozens of strange half-empty bookings for job numbers that never existed, created by status updates with typos in the key. His friend Ethan, a developer, sat down with him and a coffee. This page is what Ethan explained: the real difference between the two calls, the three meanings of "update if not exists" and the exact expression for each, how upsert works and how to tell a create from an update, if_not_exists() and counters, boto3 examples for the client and the Table resource, every error message you will hit, what each write costs, batch and bulk upserts, PartiQL, and Step Functions.
New to DynamoDB? Our plain-English guide to Amazon DynamoDB explains tables, items, keys and attributes in about ten minutes. You can follow this page without it; every step starts from zero.
DynamoDB PutItem vs UpdateItem: the difference in one minute
Picture each DynamoDB item as an index card in a box, filed by its key. A card for booking job#1042 might hold the customer's name, phone number, device and status.
PutItem is writing a fresh card and dropping it into the slot. If a card with that key is already there, the old card goes in the bin. Whatever was on the old card and isn't on the new one is lost. That's what happened to Jake's customer: his script wrote a new card with only the key and the status, and her phone number went in the bin with the old one.
UpdateItem is taking the card out, crossing out or adding the lines you mention, and putting it back. Lines you don't mention stay untouched. And if there's no card in that slot, UpdateItem writes a new one containing the key plus the lines you mentioned. It doesn't fail, and it doesn't warn you.
| PutItem | UpdateItem | |
|---|---|---|
| Item doesn't exist | Creates it with exactly the attributes you sent | Creates it with the key plus the attributes your expression sets |
| Item exists | Replaces it completely; unsent attributes are deleted | Changes only the attributes your expression names |
| What you send | The whole item, as Item | The key, plus an UpdateExpression (SET, REMOVE, ADD, DELETE) |
| Works on the current value | No; it never reads the old item | Yes: counters (n = n + :one), list appends, if_not_exists |
| ReturnValues options | NONE, ALL_OLD | NONE, ALL_OLD, UPDATED_OLD, ALL_NEW, UPDATED_NEW |
| Conditions | ConditionExpression | ConditionExpression |
| Write cost | Larger of old and new item size, per 1 KB | Larger of before and after item size, per 1 KB |
| In BatchWriteItem | Yes (as PutRequest) | No; batches can't update |
| Atomic | Yes, for that one item | Yes, for that one item |
So the choice isn't about speed or price. It's about meaning. Ask three questions, in this order:
- Does the new value depend on the old one (a counter, a list you append to, a "set only if missing" field)? Use UpdateItem; PutItem can't read the old item.
- Does this piece of code have every attribute of the item in hand? If not, or if other code writes other attributes of the same item, use UpdateItem. If it always writes the complete item, such as creating a record from a full form, PutItem is fine.
- Must the write refuse to create, or refuse to overwrite? Add the matching condition from the next section to whichever call you picked.
Jake: "So put_item didn't lose her phone number by accident. It threw the card away on purpose because I told it to."
Ethan: "Exactly. You handed the filing clerk a card with two lines on it and said 'this is booking 1042 now.' The clerk is very literal. update_item is the clerk who edits the card instead of replacing it."
"DynamoDB update item if not exists" means three different things
People type the same five words for three different jobs, and each job needs a different expression. Picking the wrong one is how you end up with either overwritten data or ghost items.
| What you actually want | Call | Expression | If the condition fails |
|---|---|---|---|
| Create the item only if it doesn't exist (insert-only, never overwrite) | put_item | ConditionExpression="attribute_not_exists(pk)" | ConditionalCheckFailedException; existing item untouched |
| Update the item only if it already exists (no ghost items) | update_item | ConditionExpression="attribute_exists(pk)" | ConditionalCheckFailedException; nothing is created |
| Set an attribute only if it's missing, on an item that may or may not exist | update_item | SET created_at = if_not_exists(created_at, :now) | Never fails; keeps the existing value |
| Create or update, whatever is there (plain upsert) | update_item | No condition | Never fails |
Create only if it doesn't exist
Use a put with a condition on the partition key attribute. Every item in the table must have its key attributes, so attribute_not_exists(pk) can only be true when there is no item with this key at all. This is the classic "insert if not exists," and it's what you want for sign-ups, order numbers, idempotency keys, or anything where a second write with the same key means a bug or a duplicate request:
import boto3
ddb = boto3.client("dynamodb", region_name="us-east-1")
def create_booking(job_id, customer, phone):
try:
ddb.put_item(
TableName="bookings",
Item={
"pk": {"S": job_id},
"customer": {"S": customer},
"phone": {"S": phone},
"status": {"S": "new"},
},
ConditionExpression="attribute_not_exists(pk)",
)
return True
except ddb.exceptions.ConditionalCheckFailedException:
return False # a booking with this job number already exists
Update only if it already exists
This is the fix for Jake's ghost bookings. Without a condition, a status update with a mistyped job number quietly creates a new item holding only the key and the status. Add attribute_exists(pk) and the same typo raises ConditionalCheckFailedException instead, which your code can report:
def set_status(job_id, status):
try:
ddb.update_item(
TableName="bookings",
Key={"pk": {"S": job_id}},
UpdateExpression="SET #s = :s",
ConditionExpression="attribute_exists(pk)",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={":s": {"S": status}},
)
return True
except ddb.exceptions.ConditionalCheckFailedException:
return False # no booking with that job number: nothing was created
The #s placeholder is there because status is a DynamoDB reserved word; the error section explains.
Composite keys: the condition checks one item, not the partition
If your table has a partition key and a sort key, attribute_not_exists(pk) still only looks at the one item with the full key you're writing. It does not mean "no item in this partition." Writing shop = main, job = 1002 with that condition succeeds even when shop = main, job = 1001 exists, because 1002 is a different item. Checking the partition key alone is enough to mean "this exact item is new"; you don't need attribute_not_exists(pk) AND attribute_not_exists(sk), though adding it does no harm.
Jake: "So the ghost bookings were update_item being too helpful."
Ethan: "Right. You asked it to change the status on card 1402, there was no card 1402, so it made one. Adding attribute_exists is telling the clerk 'if the card isn't in the box, come back and tell me instead of writing a new one.'"
DynamoDB upsert: UpdateItem already does it, and how to tell a create from an update
Because UpdateItem creates missing items, a plain update with no condition is a complete upsert: one request, atomic, no read first. That's also why there's no upsert_item call to look for. The questions that remain are how to fill in creation-only fields, and how to know afterwards which of the two happened.
A common upsert sets updated_at on every write but created_at only the first time. One expression does both, because if_not_exists leaves an existing created_at alone:
from datetime import datetime, timezone
def upsert_booking(job_id, customer, device):
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
resp = ddb.update_item(
TableName="bookings",
Key={"pk": {"S": job_id}},
UpdateExpression=(
"SET customer = :c, device = :d, "
"created_at = if_not_exists(created_at, :now), updated_at = :now"
),
ExpressionAttributeValues={
":c": {"S": customer},
":d": {"S": device},
":now": {"S": now},
},
ReturnValues="ALL_OLD",
)
created = "Attributes" not in resp
return created
The last two lines are the trick for telling a create from an update. With ReturnValues="ALL_OLD", DynamoDB returns the item as it was before the write. For an existing item, the response has an Attributes key with the old values. For a brand-new item there was nothing before, so ALL_OLD has no effect and the response has no Attributes key at all. The same check works on put_item: an Attributes key in the response means the put overwrote something. Return values are strongly consistent and cost no read capacity, so this costs nothing beyond a slightly larger response.
If you want the item as it looks after the write, use ALL_NEW instead; it returns the whole item whether it was created or updated, so it can't tell the two apart. UPDATED_NEW returns only the attributes your expression touched, after the write, which is handy for reading back a counter in the same request.
if_not_exists() explained: per attribute, not per item
if_not_exists(path, value) is a function you use inside a SET action. If the item doesn't have an attribute at path, it evaluates to value; if it does, it evaluates to the attribute's current value. It's how you say "set this unless it's already set."
Two misreadings cause most of the confusion around it:
- It checks one attribute, not the item.
if_not_exists(created_at, :now)asks "does this item have a created_at?" It doesn't ask whether the item exists. On a brand-new item the answer is no, so it uses:now; on an old item withoutcreated_atit also uses:now. - It isn't a condition. It never makes the request fail. If you want the write to fail when something exists, that's
attribute_not_existsin a ConditionExpression. The two names look alike and do very different jobs.
Counters on items that might not exist
Here is the error that teaches most people about if_not_exists. You want a visit counter, so you write SET visits = visits + :one. It works on every item that already has visits, and on the first visit to a new item it fails with:
ValidationException: The provided expression refers to an attribute that does not exist in the item
The expression tries to read visits before it exists. Two fixes, both atomic and both safe on brand-new items:
# Fix 1: give the missing attribute a starting value
UpdateExpression="SET visits = if_not_exists(visits, :zero) + :one"
ExpressionAttributeValues={":zero": {"N": "0"}, ":one": {"N": "1"}}
# Fix 2: ADD treats a missing number as 0
UpdateExpression="ADD visits :one"
ExpressionAttributeValues={":one": {"N": "1"}}
ADD is the shorter of the two: for a number that doesn't exist yet, DynamoDB starts from 0 and adds your value, and it works whether or not the item exists. Use the SET form when you want the counter to start somewhere other than zero, or when you're already writing other attributes with SET in the same expression.
Appending to a list that might not exist
list_append has the same problem as the counter: appending to a missing list fails. Wrap the list in if_not_exists with an empty list as the default:
ddb.update_item(
TableName="bookings",
Key={"pk": {"S": "job#1042"}},
UpdateExpression="SET parts = list_append(if_not_exists(parts, :empty), :new)",
ExpressionAttributeValues={
":empty": {"L": []},
":new": {"L": [{"S": "screen"}]},
},
)
Run it twice with "screen" and then "battery," and the item ends up with parts = ["screen", "battery"]. Swap the operands, list_append(:new, if_not_exists(parts, :empty)), to add to the front instead.
Jake: "So if_not_exists is like writing 'first seen' on the card in pen, only if that line is blank."
Ethan: "That's it. And attribute_not_exists is a different tool: it's the clerk refusing the whole job if a line is already filled in."
PutItem: the full replace, and how to make it safe
PutItem's replace-everything behavior is a feature when your code always writes the complete item, and a data-loss bug when it doesn't. Here's exactly what happens. A booking holds four attributes:
{"pk": "job#1042", "customer": "Ana", "phone": "555-0100", "status": "new"}
Jake's script then ran a put with only two:
ddb.put_item(
TableName="bookings",
Item={"pk": {"S": "job#1042"}, "status": {"S": "repaired"}},
)
Afterwards the item is {"pk": "job#1042", "status": "repaired"}. The customer name and phone number are gone, with no error and no warning. Three ways to keep PutItem from surprising you:
- Only put complete items. If a piece of code doesn't have every attribute, it should use UpdateItem.
- Guard creation with
attribute_not_exists(pk)so a put can never replace an existing item, as in the create-only example. - Detect overwrites with
ReturnValues="ALL_OLD". If the response containsAttributes, the put replaced an item, and you have the old version right there to log or restore.
For optimistic locking, where a write should succeed only if nobody else changed the item since you read it, keep a version number on the item and put with ConditionExpression="version = :expected". Our guide to DynamoDB ConditionalCheckFailedException covers that pattern, and what to do when the condition fails, in depth.
boto3 update_item examples: the client and the Table resource
boto3 gives you two ways to talk to DynamoDB, and the examples you find online mix them freely, which is a common source of confusion. The client (boto3.client("dynamodb")) uses DynamoDB's typed format, where every value is wrapped with its type: {"S": "Ana"} for a string, {"N": "3"} for a number (written as a string). The Table resource (boto3.resource("dynamodb").Table("bookings")) converts plain Python values for you. Here is the same update both ways:
import boto3
from decimal import Decimal
# Client: typed values
client = boto3.client("dynamodb", region_name="us-east-1")
client.update_item(
TableName="bookings",
Key={"pk": {"S": "job#1042"}},
UpdateExpression="SET price = :p, technician = :t",
ExpressionAttributeValues={":p": {"N": "89.50"}, ":t": {"S": "Jake"}},
ReturnValues="UPDATED_NEW",
)
# Table resource: plain Python values (numbers as Decimal)
table = boto3.resource("dynamodb", region_name="us-east-1").Table("bookings")
table.update_item(
Key={"pk": "job#1042"},
UpdateExpression="SET price = :p, technician = :t",
ExpressionAttributeValues={":p": Decimal("89.50"), ":t": "Jake"},
ReturnValues="UPDATED_NEW",
)
The resource has one sharp edge: it refuses Python floats. Pass 89.5 and you get TypeError: Float types are not supported. Use Decimal types instead. before anything is sent. Use Decimal("89.50"), built from a string so no binary rounding sneaks in, and expect numbers to come back as Decimal when you read them.
Updating several attributes from a dictionary
Updating several attributes at once is the next thing almost everyone needs, and the answer is a small helper that builds the expression for you. It uses a #name placeholder for every attribute, which also makes reserved words like status and name harmless:
def update_fields(table, key, fields, only_if_exists=True):
"""Update the given attributes of one item. fields = {"status": "repaired", ...}"""
names, values, parts = {}, {}, []
for i, (attr, value) in enumerate(fields.items()):
names[f"#a{i}"] = attr
values[f":v{i}"] = value
parts.append(f"#a{i} = :v{i}")
kwargs = dict(
Key=key,
UpdateExpression="SET " + ", ".join(parts),
ExpressionAttributeNames=names,
ExpressionAttributeValues=values,
ReturnValues="ALL_NEW",
)
if only_if_exists:
kwargs["ConditionExpression"] = "attribute_exists(" + next(iter(key)) + ")"
return table.update_item(**kwargs)["Attributes"]
update_fields(table, {"pk": "job#1042"}, {"status": "repaired", "name": "Ana", "price": Decimal("89.50")})
The only_if_exists switch defaults to the safe choice for status updates. Set it to False when you mean upsert. Key attributes can't go in fields; DynamoDB refuses to update them, as the next section shows.
The errors you will hit, with the exact messages
Every DynamoDB error here comes back as a ClientError from boto3 with the code in err.response["Error"]["Code"]; the last row is boto3's own TypeError, raised before anything is sent. Our guide to boto3 error handling shows how to catch them by class or by code. The messages are specific enough that you can match them by eye:
| Code and message | What it means | Fix |
|---|---|---|
| ConditionalCheckFailedException: The conditional request failed | Your ConditionExpression was false: the item existed for a create-only put, or didn't for an update-only update | Expected in the patterns above; handle it, don't retry it |
| ValidationException: Invalid UpdateExpression: Attribute name is a reserved keyword; reserved keyword: status | You used a reserved word (status, name, date, data, count and hundreds more) directly in the expression | Use a placeholder: SET #s = :s with ExpressionAttributeNames={"#s": "status"} |
| ValidationException: The provided expression refers to an attribute that does not exist in the item | SET n = n + :one or list_append(parts, …) on a missing attribute | if_not_exists(n, :zero) + :one, or ADD n :one |
| ValidationException: One or more parameter values were invalid: Cannot update attribute pk. This attribute is part of the key | Your expression tries to change a key attribute | Keys can't change; put a new item with the new key and delete the old one |
| ValidationException: Invalid UpdateExpression: Two document paths overlap with each other; must remove or rewrite one of these paths | The same attribute appears twice, such as in SET and REMOVE | Touch each attribute once per expression |
| ValidationException: Value provided in ExpressionAttributeValues unused in expressions: keys: {:b} | You passed a placeholder value the expression never uses | Remove it; DynamoDB rejects unused values |
| ValidationException: Invalid UpdateExpression: An expression attribute value used in expression is not defined; attribute value: :a | The expression uses :a but you didn't pass it | Add it to ExpressionAttributeValues (check spelling and the colon) |
| ValidationException: One or more parameter values are not valid. The AttributeValue for a key attribute cannot contain an empty string value | A key attribute is an empty string (other attributes may be empty) | Validate key values before the call |
| ValidationException: Can not use both expression and non-expression parameters in the same request | You mixed the legacy AttributeUpdates or Expected with UpdateExpression or ConditionExpression | Use only the expression parameters; the legacy ones are kept for old code |
| TypeError: Float types are not supported. Use Decimal types instead. | Python float passed to the Table resource (raised by boto3, not DynamoDB) | Decimal("89.50") |
Two more come from the key rather than the expression. If the Key you pass doesn't match the table's key schema (a missing sort key, a wrong attribute name or type, or an extra attribute), DynamoDB refuses it with a ValidationException before touching any item; our page on "the provided key element does not match the schema" walks through each cause. And an item that would grow past 400 KB is refused with "Item size has exceeded the maximum allowed size," which matters for lists you keep appending to.
Three quiet surprises: REMOVE creates items, sets vanish, retries double-count
An update that only removes can create an item
UpdateItem creates missing items no matter what the expression does. An update that only removes an attribute, such as REMOVE phone, sent for a key that doesn't exist, leaves behind a new item containing nothing but the key. Cleanup scripts that strip an attribute across a list of keys are the usual culprit: every stale key in the list becomes a ghost item. Add ConditionExpression="attribute_exists(pk)" to any update that should only touch existing items, removals included.
Deleting the last value from a set deletes the attribute
DynamoDB sets (string, number and binary sets) can't be empty. ADD parts :p with a string set adds values, creating the set if needed, and DELETE parts :p removes them. When you delete the last value, the attribute disappears from the item entirely. Code that reads item["parts"] afterwards gets a KeyError; read it with item.get("parts", set()).
Counters aren't idempotent, so retries can double-count
An atomic counter (ADD visits :one) is safe against two writers at once, because DynamoDB applies writes to an item in the order it receives them. It isn't safe against retries. If a request times out after DynamoDB applied it, and your SDK or code sends it again, the counter goes up twice. For a page-view counter that's fine. For anything involving money or stock levels, use a conditional update that checks the value you expect, such as SET stock = stock - :one with ConditionExpression="stock = :expected". A conditional write that checks the same attribute it changes can be retried safely, because the second attempt finds the value already changed and fails the condition instead of applying twice.
Jake: "So a 'delete this line from every card' job can fill the box with blank cards."
Ethan: "If any of the job numbers on your list are wrong, yes. Same fix as before: tell the clerk to come back when the card isn't there."
DynamoDB PutItem vs UpdateItem cost and performance
The billing rule is the same for both calls, and it surprises people who expect an update to be cheaper. One write unit covers one write of up to 1 KB, and item sizes are rounded up to the next 1 KB. Then:
- PutItem is charged on the larger of the new item and the item it replaces.
- UpdateItem is charged on the larger of the item's size before and after the update, even if you change one small attribute. Updating a 1-byte counter on a 6 KB item costs 6 write units, not 1.
- A write whose condition fails still costs write capacity. The ConditionalCheckFailedException doesn't come free, so a create-only put that fails a million times a day still shows up on your bill.
- Transactional writes cost double: two write units per 1 KB, because DynamoDB prepares and then commits.
- Return values are free.
ALL_OLD,ALL_NEWand the rest consume no read capacity.
| Scenario | PutItem | UpdateItem |
|---|---|---|
| New 0.5 KB item | 1 write unit | 1 write unit |
| Change status on an existing 3.2 KB item | 4 write units (and you must resend all 3.2 KB) | 4 write units (send only the change) |
| Increment a counter on a 6 KB item | Not possible without reading first | 6 write units |
| Create-only write that fails because the item exists | Still billed | Still billed |
| Same write inside TransactWriteItems | Double | Double |
Two practical consequences. Large items make every small update expensive, so a frequently updated counter or status belongs in its own small item rather than on a big record. And PutItem's cost includes the network: to change one field you must send the whole item, while UpdateItem sends only the change. For speed, both are single-item writes, so pick by meaning, not by latency. On-demand tables bill the same units as write request units; our comparison of DynamoDB on-demand vs provisioned pricing turns them into dollars.
Batch upsert, bulk upsert and transactions
When you need to write many items, three tools are available, and only one of them can update.
BatchWriteItem sends up to 25 put or delete requests in one call, up to 16 MB in total. It can't update, and its puts take no condition, so every put in a batch is a full replace: an existing item with the same key is overwritten exactly like a single PutItem. That makes a batch a "bulk upsert" only in the replace-the-whole-item sense. It's ideal for loading complete records, such as importing a CSV, and wrong for changing one field across many items. boto3's table.batch_writer() wraps it, splits your writes into batches of 25 and resends unprocessed items for you; pass overwrite_by_pkeys=["pk"] to drop duplicate keys within one batch, which DynamoDB would otherwise reject.
table = boto3.resource("dynamodb", region_name="us-east-1").Table("bookings")
with table.batch_writer(overwrite_by_pkeys=["pk"]) as batch:
for row in rows: # each row is a complete item
batch.put_item(Item=row)
A loop of UpdateItem calls is the honest answer for "batch update": there is no batch form of UpdateItem. Run the updates in a thread pool if you need speed, and keep each one conditional if ghost items would hurt.
TransactWriteItems groups up to 100 actions (Put, Update, Delete, ConditionCheck) across tables, all-or-nothing, with a 4 MB total. Its Update action behaves like UpdateItem, upsert included, and each action can carry its own condition. Use it when several writes must succeed or fail together, such as booking a repair slot and decrementing a part's stock. Remember the double write cost, and that no two actions in one transaction can target the same item.
PartiQL upsert: INSERT and UPDATE behave the opposite way
DynamoDB also accepts SQL-style statements through PartiQL (execute_statement in boto3), and the semantics flip compared with the API calls:
| Statement | Item exists | Item doesn't exist |
|---|---|---|
INSERT INTO bookings VALUE {'pk': 'job#1042', ...} | Fails: DuplicateItem, "Duplicate primary key exists in table" | Creates it |
UPDATE bookings SET customer = 'Ana' WHERE pk = 'job#1042' | Updates it | Fails: ConditionalCheckFailedException |
So PartiQL's INSERT is the create-only put, and PartiQL's UPDATE is the update-only update, each with the condition built in. There's no single PartiQL statement that does both; UPSERT isn't part of DynamoDB's PartiQL, and a statement starting with it is rejected as not well formed. If you want an upsert, the API's UpdateItem is the tool; if you like PartiQL's safer defaults, try the UPDATE and fall back to INSERT when it fails.
Step Functions PutItem and UpdateItem
Step Functions can write to DynamoDB without any Lambda code, through its optimized integration. It supports exactly four actions: GetItem, PutItem, UpdateItem and DeleteItem (anything else, such as BatchWriteItem or CreateTable, goes through the general AWS SDK integration). The semantics are the DynamoDB ones, unchanged: arn:aws:states:::dynamodb:putItem replaces the whole item, and arn:aws:states:::dynamodb:updateItem upserts. Values use the same typed format as the boto3 client, so a number travels as a string inside {"N": "…"}, and parameter names are written in PascalCase (TableName, Key, UpdateExpression, ConditionExpression). Everything on this page applies: add attribute_exists(pk) to an updateItem state that should never create items, and catch the conditional failure in the state's Catch block. One naming detail matters there: errors from the optimized integration are prefixed DynamoDB. with a capital DB, such as DynamoDB.ConditionalCheckFailedException, while the SDK integration uses DynamoDb., so a Catch written for one won't match the other.
Still stuck? Troubleshooting by symptom
Attributes disappeared after a write
A PutItem replaced the item with a version that didn't include them. Work through it in this order:
- Find the code that writes this item with put_item; search for the table name and
put_item. - Switch it to update_item with a SET for only the attributes it owns, or make sure it always sends the complete item.
- Recover the lost values: if point-in-time recovery is on, or a stream records old images, the old values are there.
- Add
ReturnValues="ALL_OLD"to the puts that remain, so each one hands back what it replaced.
Items appeared that nobody created
An UpdateItem without a condition ran for keys that didn't exist, often from a typo, a stale list, or a REMOVE-only cleanup. The giveaway is an item holding only its key plus one or two attributes. Add attribute_exists(pk) to updates that should only touch existing items.
ConditionalCheckFailedException on every create
Your create-only put uses a key that already exists, or the condition names an attribute that isn't the key. Check that attribute_not_exists(…) names the partition key attribute exactly as the table defines it, case included. To see what's in the way, add ReturnValuesOnConditionCheckFailure="ALL_OLD"; the existing item then arrives in err.response["Item"].
if_not_exists doesn't seem to do anything
It only fills a missing attribute; if the attribute exists, the old value wins, which is the point. If you expected the whole write to stop when the item exists, you wanted attribute_not_exists(pk) in a ConditionExpression instead.
My counter went up by two
A retry resent an increment that had already been applied. Atomic counters aren't idempotent. Accept the occasional overcount, or switch to a conditional update on the counter's expected value.
Works in DynamoDB Local, fails in AWS
Usually credentials, Region or permissions rather than expression behavior. Check that the IAM role allows dynamodb:UpdateItem or dynamodb:PutItem on the table's ARN, that the client points at the right Region, and that boto3 can find credentials at all. Some error messages are also worded differently in DynamoDB Local than in the real service, especially for key mistakes, so match on the error code rather than the message text. Our guide to boto3 NoCredentialsError covers missing credentials.
DynamoDB PutItem vs UpdateItem: frequently asked questions
What is the difference between PutItem and UpdateItem in DynamoDB?
PutItem writes a complete item: if an item with that key exists, it is replaced and any attributes you didn't send are deleted. UpdateItem changes only the attributes named in its UpdateExpression, keeps the rest, and creates the item if it doesn't exist.
How do I update an item in DynamoDB only if it exists?
Call update_item with ConditionExpression="attribute_exists(pk)", using your partition key attribute name. If no item has that key, DynamoDB raises ConditionalCheckFailedException and creates nothing. Without the condition, UpdateItem would create a new item.
How do I insert an item in DynamoDB only if it doesn't exist?
Call put_item with ConditionExpression="attribute_not_exists(pk)". Every item has its key attribute, so the condition is true only when no item with that key exists. If one does, the put fails with ConditionalCheckFailedException and the existing item is untouched.
Does DynamoDB UpdateItem create the item if it doesn't exist?
Yes. UpdateItem is an upsert: for a missing key it creates an item with the key and the attributes your expression sets. That happens even when the expression only removes attributes, which leaves an item holding just the key.
How do I do an upsert in DynamoDB?
Use update_item with no condition. It creates the item if it's missing and updates it if it exists, in one atomic request. Use if_not_exists for fields such as created_at that should be set only on the first write.
What does if_not_exists do in DynamoDB?
Inside a SET action, if_not_exists(path, value) evaluates to value when the item has no attribute at path, and to the current attribute value otherwise. It works per attribute, never makes the request fail, and is different from the attribute_not_exists condition function.
What is the difference between if_not_exists and attribute_not_exists?
if_not_exists is a value function used inside SET to supply a default for a missing attribute. attribute_not_exists is a condition function used in ConditionExpression; when it's false, the whole write fails with ConditionalCheckFailedException.
How do I know if UpdateItem created a new item or updated an existing one?
Pass ReturnValues="ALL_OLD". For an existing item the response contains Attributes with the old values; for a newly created item there was nothing before, so the response has no Attributes key.
Is PutItem or UpdateItem cheaper in DynamoDB?
Neither. Both are billed on the larger of the item's size before and after the write, rounded up to 1 KB per write unit. Updating one attribute of a 6 KB item costs 6 write units. Failed conditional writes still cost capacity.
Is PutItem faster than UpdateItem?
Not in a way worth choosing on; both are single-item writes. PutItem must send the whole item over the network, while UpdateItem sends only the change. Choose by meaning: complete replacement versus partial change.
How do I increment a counter in DynamoDB if the attribute doesn't exist?
Use ADD visits :one, which treats a missing number as 0, or SET visits = if_not_exists(visits, :zero) + :one. A plain SET visits = visits + :one fails on a missing attribute with "The provided expression refers to an attribute that does not exist in the item."
How do I update multiple attributes with boto3 update_item?
List them in one SET clause separated by commas, such as SET #a0 = :v0, #a1 = :v1, with matching ExpressionAttributeNames and ExpressionAttributeValues. A small helper can build this from a dictionary and add attribute_exists for update-only behavior.
Can BatchWriteItem update items in DynamoDB?
No. BatchWriteItem supports only put and delete requests, up to 25 per call, with no conditions. A put in a batch replaces any existing item with the same key. For partial updates on many items, loop over update_item or use TransactWriteItems.
Is DynamoDB UpdateItem atomic?
Yes, for the single item it writes: all changes in one UpdateExpression apply together or not at all, and concurrent writes to that item are applied in the order DynamoDB receives them. Atomic counters are still not idempotent if a request is retried.
Why do I get "Attribute name is a reserved keyword" in DynamoDB?
Your expression uses a reserved word such as status, name, date or count directly. Replace it with a placeholder, SET #s = :s, and pass ExpressionAttributeNames={"#s": "status"}.
How do I upsert with PartiQL in DynamoDB?
PartiQL has no upsert statement. INSERT fails with DuplicateItem if the key exists, and UPDATE fails with ConditionalCheckFailedException if it doesn't. Use the UpdateItem API for upserts, or try UPDATE and fall back to INSERT.
Why does the boto3 Table resource say "Float types are not supported"?
The Table resource converts Python values to DynamoDB types and refuses floats to avoid silent rounding. Pass numbers as Decimal, preferably built from a string such as Decimal("89.50").
Can Step Functions update a DynamoDB item?
Yes. The optimized integration supports GetItem, PutItem, UpdateItem and DeleteItem with the same semantics as the API, so updateItem upserts unless you add a condition. Errors arrive with the DynamoDB. prefix, such as DynamoDB.ConditionalCheckFailedException.
Jake's booking code now has two kinds of write and no ambiguity between them. New bookings go in with a create-only put, so a double-tapped Book button can't duplicate a job. Status changes go through an update that refuses unknown job numbers and reports them, and the cleanup script that once filled the table with ghosts checks before it touches anything. The customer whose number vanished got it restored from an old export and a free screen protector. Ethan's parting advice was the line below.
📌 If you keep one line from this page
PutItem replaces, UpdateItem upserts; say "only if new" with attribute_not_exists and "only if there" with attribute_exists.
Both cost the larger of before and after, so keep hot counters on small items.
Revision note. Written October 11, 2026, for everyone who lost a field to a put that meant well. "Mend the roof before it rains," goes the old saying; a one-line condition is the cheapest roof a table will ever get.