DynamoDB PutItem in Go (AWS SDK v2)

PutItem writes a whole item and replaces any existing item with the same primary key (item-based actions covers how that differs from UpdateItem). In AWS SDK for Go v2 the interesting part is not the call, it is what your Go values turn into on the way out.

Code

package main

import (
	"context"
	"errors"
	"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)

	_, err = client.PutItem(ctx, &dynamodb.PutItemInput{
		TableName: aws.String("Music"),
		Item: map[string]types.AttributeValue{
			"Artist":     &types.AttributeValueMemberS{Value: "Arturo Sandoval"},
			"SongTitle":  &types.AttributeValueMemberS{Value: "Cubano Chant"},
			"AlbumTitle": &types.AttributeValueMemberS{Value: "Danzon"},
			"Year":       &types.AttributeValueMemberN{Value: "1994"},
			"Awards":     &types.AttributeValueMemberN{Value: "0"},
		},
		ConditionExpression: aws.String("attribute_not_exists(#cond0) AND attribute_not_exists(#cond1)"),
		ExpressionAttributeNames: map[string]string{
			"#cond0": "Artist",
			"#cond1": "SongTitle",
		},
	})
	if err != nil {
		var ccf *types.ConditionalCheckFailedException
		if errors.As(err, &ccf) {
			fmt.Println("A song with that key already exists — not overwritten")
			return
		}
		log.Fatalf("put item: %v", err)
	}
	fmt.Println("Song written")
}

Explanation

attributevalue.MarshalMap is the shortcut, and it has opinions. Feeding a struct to github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue instead of hand-building the map[string]types.AttributeValue above is the normal move. Here is what it actually produced for a struct with a time.Time, an untouched string field and a nil *int:

Artist     => &types.AttributeValueMemberS{Value:"Arturo Sandoval"}
SongTitle  => &types.AttributeValueMemberS{Value:"Cubano Chant"}
Released   => &types.AttributeValueMemberS{Value:"1994-01-01T00:00:00Z"}
Notes      => &types.AttributeValueMemberS{Value:""}
Rating     => &types.AttributeValueMemberNULL{Value:true}

Three things to take from that. time.Time becomes an RFC 3339 string, not a Unix number, so a timestamp sort key sorts lexicographically and will only behave if every value is zero-padded and in the same zone. An untouched string becomes a real empty-string attribute rather than being left out. And a nil pointer becomes NULL, which is an attribute that exists.

A NULL attribute defeats attribute_not_exists. That is the one that costs an afternoon. Write an item whose Rating came from a nil *int, then guard the next write with attribute_not_exists(Rating) and it fails:

ConditionalCheckFailedException: The conditional request failed

DynamoDB is right: the attribute is there, holding NULL. The fix is the struct tag, dynamodbav:"Rating,omitempty", which drops the field instead of nulling it. The same tag stops the empty-string attribute, which matters because those break sparse indexes and, in a key attribute, are rejected outright:

ValidationException: One or more parameter values are not valid. The AttributeValue for a key attribute cannot contain an empty string value. Key: Artist

errors.As, and never string matching. The Go SDK wraps every service error in smithy's operation error, so the string you get from err.Error() is not the message DynamoDB sent:

operation error DynamoDB: PutItem, https response error StatusCode: 400, RequestID: 6886fce0-b246-4762-8a8b-0c6dab813040, ConditionalCheckFailedException: The conditional request failed

errors.As(err, &ccf) unwraps it and returned true above; ccf.ErrorMessage() then gives the bare The conditional request failed. A strings.Contains check on the outer string works today and breaks the day the SDK changes its wrapper format.

The typed error can carry the item that blocked you. Set ReturnValuesOnConditionCheckFailure: types.ReturnValuesOnConditionCheckFailureAllOld on the input and ccf.Item comes back populated: five attributes in the run above, including Year as &types.AttributeValueMemberN{Value:"1994"}. That saves the read-after-failure round trip most retry loops do by hand. ReturnValues: types.ReturnValueAllOld is the success-path equivalent (ReturnValues).

Numbers are strings, and that is not a Go quirk. AttributeValueMemberN{Value: "1994"} looks wrong to anyone coming from a typed language, but DynamoDB's N type is a decimal transported as text precisely so nothing has to round-trip through a float64. strconv.FormatInt and strconv.FormatFloat are the conversion; attributevalue does it for you.

What you marshal is what you pay for. A put of a ~15 KB item reported "CapacityUnits": 15 with ReturnConsumedCapacity. Writes round up per 1 KB, so an accidental blob field, or a MarshalMap that emitted attributes you meant to drop, shows up directly on the bill.

Do it visually

Since MarshalMap decides what actually lands in the item, it is worth knowing what that item weighs. The free DynamoDB item size calculator takes the marshalled JSON and returns the byte size and the write units it rounds to.

To write and edit items against your own tables — a form per attribute, type pickers, copy the result back out as Go — download DynoTable.

References

Reproduced 2026-07-28 on go1.26.5 with aws-sdk-go-v2/service/dynamodb v1.62.1 and feature/dynamodb/attributevalue v1.20.55, against DynamoDB Local (amazon/dynamodb-local) on port 9000. The marshalled values, the error strings and the capacity reading are captured output, copied verbatim.

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.