DynamoDB UpdateItem in Go (AWS SDK v2)

UpdateItem is straightforward in Go; the awkward part is that map[string]types.AttributeValue is a map of interfaces, so both the values you send and the ones you get back are pointers to one of nine member structs. That single design choice explains most of the friction below, starting with the fact that the fmt.Println in this snippet does not print your item.

Code

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

func main() {
	ctx := context.TODO()
	cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-1"))
	if err != nil {
		log.Fatalf("load config: %v", err)
	}
	client := dynamodb.NewFromConfig(cfg)

	out, err := client.UpdateItem(ctx, &dynamodb.UpdateItemInput{
		TableName: aws.String("Music"),
		Key: map[string]types.AttributeValue{
			"Artist":    &types.AttributeValueMemberS{Value: "Arturo Sandoval"},
			"SongTitle": &types.AttributeValueMemberS{Value: "Cubano Chant"},
		},
		UpdateExpression: aws.String("SET #upd0 = :updValue0, #upd1 = :updValue1 ADD #upd2 :updValue2"),
		ExpressionAttributeNames: map[string]string{
			"#upd0": "Genre",
			"#upd1": "Year",
			"#upd2": "Awards",
		},
		ExpressionAttributeValues: map[string]types.AttributeValue{
			":updValue0": &types.AttributeValueMemberS{Value: "Latin Jazz"},
			":updValue1": &types.AttributeValueMemberN{Value: "1994"},
			":updValue2": &types.AttributeValueMemberN{Value: "1"},
		},
		ReturnValues: types.ReturnValueAllNew,
	})
	if err != nil {
		log.Fatalf("update item: %v", err)
	}
	fmt.Println(out.Attributes) // the item after the update
}

Explanation

  • AttributeValueMemberN.Value is a string, not a numeric type, and attributevalue.Marshal keeps it that way: int64(9007199254740993) marshals to "9007199254740993" exactly. DynamoDB numbers carry 38 digits of precision, more than any Go float, so the SDK never converts. You do the parsing, at the edge, deliberately.

  • ReturnValues: types.ReturnValueAllNew — the constant's value is the literal "ALL_NEW". types.ReturnValueAllNew.Values() lists all five (NONE, ALL_OLD, UPDATED_OLD, ALL_NEW, UPDATED_NEW) if you would rather read them off the type than the docs.

  • UpdateExpression is an *string you build yourself, or hand off to the expression package below. Either way DynamoDB is the only parser: ADD #upd2 :updValue2 gives the atomic increment, attribute_exists(Artist) in a ConditionExpression makes the call update-only instead of an upsert, and the clause grammar lives in update expressions.

  • ValidationException has no Go type. types/errors.go defines 35 error structs, including ConditionalCheckFailedException, TransactionCanceledException, ProvisionedThroughputExceededException and TransactionConflictException. Validation failures are not among them, so a bad expression surfaces as a generic smithy.APIError you can only recognise by string:

    operation error DynamoDB: UpdateItem, https response error StatusCode: 400, RequestID: 702d67f1-4f15-43dc-b3e9-cea691878801, api error ValidationException: Invalid UpdateExpression: Attribute name is a reserved keyword; reserved keyword: Year

    Note the api error prefix, which the modelled ConditionalCheckFailedException below does not get. Its presence is a decent signal that errors.As against a concrete type is not going to help you here.

  • A failed condition, by contrast, is a real type. var ccf *types.ConditionalCheckFailedException; errors.As(err, &ccf) matches, and with ReturnValuesOnConditionCheckFailure: types.ReturnValuesOnConditionCheckFailureAllOld on the input, ccf.Item arrives populated with the item as it actually was. ccf.ErrorMessage() is the bare The conditional request failed, without the transport preamble that err.Error() prepends.

Reading the result back

fmt.Println(out.Attributes) on an interface map prints addresses:

map[Artist:0x1c8cf589e060 Awards:0x1c8cf589e078 Genre:0x1c8cf589e090 SongTitle:0x1c8cf589e0c0 Year:0x1c8cf589e0a8]

Two ways out. Unmarshal into a struct, which is what most code should do:

var song struct {
	Artist string
	Genre  string
	Year   int
	Awards int
}
err = attributevalue.UnmarshalMap(out.Attributes, &song)
// {Artist:Arturo Sandoval Genre:Latin Jazz Year:1994 Awards:1}

Or assert the one member you care about, and remember .Value is a string:

n, ok := out.Attributes["Awards"].(*types.AttributeValueMemberN)
if ok {
	awards, _ := strconv.Atoi(n.Value) // "1" -> 1
	fmt.Println(awards)
}

Let the expression package write it

Go is the only SDK on this site that will generate the UpdateExpression for you. feature/dynamodb/expression composes the clauses and both maps:

upd := expression.Set(expression.Name("Genre"), expression.Value("Latin Jazz")).
	Set(expression.Name("Year"), expression.Value(1994)).
	Add(expression.Name("Awards"), expression.Value(1))
expr, _ := expression.NewBuilder().WithUpdate(upd).Build()

What comes out is not what you wrote:

UpdateExpression: ADD #0 :0
SET #1 = :1, #2 = :2

Names: map[#0:Awards #1:Genre #2:Year]

The builder reordered the clauses, separated them with a newline, and numbered the placeholders itself, so #0 is Awards rather than the first name you mentioned. Nothing downstream cares, but the strings are not stable across edits, which makes them a poor thing to assert on in tests. Pass expr.Update(), expr.Names() and expr.Values() straight into UpdateItemInput and never look at them.

The upside is that it aliases every name, so reserved words stop being a class of bug you can ship. If you hand-write the expression instead, run the attribute names through the DynamoDB reserved words checker first — the AWS list has 573 entries and Year, Name and Status are all on it. And if you would rather look at an item as data than as a map of interface pointers, 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.