DynamoDB in Go With the AWS SDK for Go v2 Step by Step
Use DynamoDB from Go with the AWS SDK v2: key design, conditional writes, query expressions, pagination and optimistic locking, in a compile-checked program

DynamoDB is the AWS key value and document database that you do not run: no servers, no versions to upgrade, a bill that scales with the reads and writes you make. It also has the least forgiving data modelling of any store in this cluster. You get fast, predictable access by key and almost nothing else for free, so a table designed without its queries in mind cannot be repaired by adding an index. This guide covers the model, then the AWS SDK for Go v2 from creating a table to optimistic updates.
Tested with Not run against DynamoDB. The code was compiled and vetted with Go 1.25.6 against aws-sdk-go-v2 dynamodb v1.72.1 only; behaviour described here comes from the AWS documentation.
It was not run. The lab behind this series had no DynamoDB endpoint, neither AWS nor DynamoDB Local, so the program below was compiled and passed go vet against the current SDK modules and nothing more. The API usage is real; no outputs are quoted. Behaviour described here is taken from the AWS documentation, and you should check limits and prices against the current pages before designing around them.
It belongs to the database cornerstone. The closest model in this cluster is Cassandra, and the decision between stores is covered in choosing a database.
The model: keys first
A table has a primary key, either a partition key alone or a partition key plus a sort key. The partition key is hashed to place the item on a physical partition. All items with the same partition key live together, ordered by sort key. Every access goes through the key: GetItem fetches one item by full key, Query fetches a range of items in one partition, and Scan reads the whole table. Only the first two are meant for application code.
The program's table, orders, has pk (the customer id) as the partition key and sk (a date-prefixed order id such as 2026-10-27#001) as the sort key, so "the latest orders of customer c1" is one query on one partition. The sort key was built so that its text order is time order, which lets begins_with(sk, "2026-") select a year.
Key design rules that hold up:
- Many distinct partition key values. Throughput is per partition. A key such as
status = "active"puts everything on one partition. A customer or device id spreads load. - Put the question in the key. Whatever you need to look up by must be a key, or the key of an index. There are no joins and no ad hoc filters on large tables.
- Overload keys. Single table designs store several entity types in one table, with keys like
CUSTOMER#c1andORDER#2026-10-27#001, so one query can fetch a customer and their orders together. It is powerful and hard to read; use it when you know the access patterns well. - Plan indexes up front. A global secondary index (GSI) gives a second key to query by, at the cost of storing the data again and eventual consistency on reads. A table with five unrelated access patterns usually needs a few GSIs.
Items have a size limit of 400 KB, and a single Query or Scan call returns at most 1 MB. Both shape the code.
The client
package main
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)
type Order struct {
CustomerID string `dynamodbav:"pk"`
OrderID string `dynamodbav:"sk"`
Total int `dynamodbav:"total"`
Status string `dynamodbav:"status"`
Version int `dynamodbav:"version"`
}
func newClient(ctx context.Context) (*dynamodb.Client, error) {
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("eu-west-1"))
if err != nil {
return nil, err
}
return dynamodb.NewFromConfig(cfg, func(o *dynamodb.Options) {
// Point at DynamoDB Local while developing; remove it for the real service.
o.BaseEndpoint = aws.String("http://127.0.0.1:8000")
}), nil
}
func createTable(ctx context.Context, c *dynamodb.Client) error {
_, err := c.CreateTable(ctx, &dynamodb.CreateTableInput{
TableName: aws.String("orders"),
AttributeDefinitions: []types.AttributeDefinition{
{AttributeName: aws.String("pk"), AttributeType: types.ScalarAttributeTypeS},
{AttributeName: aws.String("sk"), AttributeType: types.ScalarAttributeTypeS},
},
KeySchema: []types.KeySchemaElement{
{AttributeName: aws.String("pk"), KeyType: types.KeyTypeHash},
{AttributeName: aws.String("sk"), KeyType: types.KeyTypeRange},
},
BillingMode: types.BillingModePayPerRequest,
})
return err
}
func put(ctx context.Context, c *dynamodb.Client, o Order) error {
item, err := attributevalue.MarshalMap(o)
if err != nil {
return err
}
// The condition makes the write an insert: it fails if the key already exists.
_, err = c.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String("orders"),
Item: item,
ConditionExpression: aws.String("attribute_not_exists(pk)"),
})
var exists *types.ConditionalCheckFailedException
if errors.As(err, &exists) {
return fmt.Errorf("order %s already exists", o.OrderID)
}
return err
}
func ordersFor(ctx context.Context, c *dynamodb.Client, customer string) ([]Order, error) {
cond := expression.Key("pk").Equal(expression.Value(customer)).And(expression.Key("sk").BeginsWith("2026-"))
expr, err := expression.NewBuilder().WithKeyCondition(cond).Build()
if err != nil {
return nil, err
}
var out []Order
pager := dynamodb.NewQueryPaginator(c, &dynamodb.QueryInput{
TableName: aws.String("orders"),
KeyConditionExpression: expr.KeyCondition(),
ExpressionAttributeNames: expr.Names(),
ExpressionAttributeValues: expr.Values(),
ScanIndexForward: aws.Bool(false),
})
for pager.HasMorePages() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, err
}
var batch []Order
if err := attributevalue.UnmarshalListOfMaps(page.Items, &batch); err != nil {
return nil, err
}
out = append(out, batch...)
}
return out, nil
}
// ship moves an order forward only if nobody changed it since it was read: optimistic locking with a version attribute.
func ship(ctx context.Context, c *dynamodb.Client, o Order) error {
upd := expression.Set(expression.Name("status"), expression.Value("shipped")).Add(expression.Name("version"), expression.Value(1))
cond := expression.Name("version").Equal(expression.Value(o.Version))
expr, err := expression.NewBuilder().WithUpdate(upd).WithCondition(cond).Build()
if err != nil {
return err
}
key, _ := attributevalue.MarshalMap(map[string]string{"pk": o.CustomerID, "sk": o.OrderID})
_, err = c.UpdateItem(ctx, &dynamodb.UpdateItemInput{
TableName: aws.String("orders"), Key: key,
UpdateExpression: expr.Update(), ConditionExpression: expr.Condition(),
ExpressionAttributeNames: expr.Names(), ExpressionAttributeValues: expr.Values(),
})
return err
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
c, err := newClient(ctx)
if err != nil {
log.Fatal(err)
}
_ = createTable(ctx, c)
o := Order{CustomerID: "c1", OrderID: "2026-10-27#001", Total: 4200, Status: "new"}
fmt.Println(put(ctx, c, o))
list, err := ordersFor(ctx, c, "c1")
fmt.Println(len(list), err)
fmt.Println(ship(ctx, c, o))
}config.LoadDefaultConfig reads credentials and region from the environment, the shared config file or the instance or task role, in the usual AWS order. dynamodb.NewFromConfig builds a client, which is safe for concurrent use and should be created once. The option in newClient that sets BaseEndpoint points the client at DynamoDB Local, the downloadable emulator, for development; it is removed in production. The SDK retries throttled and transient failures itself with backoff, which you tune through the config's retryer, and every call takes a context.
DynamoDB speaks in typed attribute values ({"S": "c1"}, {"N": "4200"}). Writing those by hand is tedious, so the attributevalue package marshals Go structs with dynamodbav tags, as Order shows, and the expression package builds condition, update and key condition expressions in Go with the placeholders handled for you.
Writing safely
PutItem replaces any existing item with the same key. That is rarely what an insert wants, so the put function adds a ConditionExpression of attribute_not_exists(pk). If an item with that key exists, DynamoDB rejects the write with a ConditionalCheckFailedException, which the code detects with errors.As and turns into a clear error. A write you expect to succeed or fail on a condition is how you build "create unless it exists" without a read first, and without a race.
Conditions are the whole concurrency story in DynamoDB. Reads are not locked and there is no row lock. Instead, every write can say "only if" and the service evaluates it atomically.
Reading with Query and a paginator
ordersFor builds a key condition with the expression package: partition key equals the customer, and sort key begins with 2026-. It returns orders newest first by setting ScanIndexForward to false. The call is wrapped in dynamodb.NewQueryPaginator, and that matters: a query returns at most 1 MB of data per call, plus a LastEvaluatedKey if there is more. A paginator calls repeatedly until the result is complete. Code that makes one Query call and reads Items silently drops everything after the first megabyte, and it works in every test with small data.
Then attributevalue.UnmarshalListOfMaps turns the page into structs. Two cost facts apply. You pay for the data read, not the data returned, so a filter expression does not make a query cheaper: it only trims the response after the read. And a Scan reads, and bills, the entire table, so use it for one off maintenance, never in a request path.
Reads are eventually consistent by default and cost half as much as strongly consistent ones; set ConsistentRead: aws.Bool(true) on GetItem and Query when a read must see the latest write. GSIs cannot do strongly consistent reads.
Optimistic locking
The ship function moves an order to shipped only if its version still equals the value the caller read, and increments it:
- the update expression is
SET #status = :shipped ADD #version :one; - the condition is
#version = :v.
If another writer changed the item in between, the condition fails with the same ConditionalCheckFailedException, and the caller rereads and retries, or reports a conflict. This is the version column technique from optimistic locking, with the condition evaluated by the service. For a counter, skip the version: ADD is atomic.
Transactions and batches
TransactWriteItems groups up to 100 operations, across items and tables, into an all or nothing transaction, and TransactGetItems reads a consistent set. Transactional operations cost twice the normal capacity, so use them where you need atomicity, such as moving a balance between two items. BatchWriteItem sends up to 25 puts or deletes in one call and is not atomic: it can return UnprocessedItems that you must resend with backoff. BatchGetItem similarly returns unprocessed keys. Treat a non empty unprocessed result as normal, not as an error.
Capacity, throttling and cost
Tables run in one of two modes. On demand (PayPerRequest, used in createTable) charges per request and scales without planning, and it is the right start. Provisioned capacity is cheaper at steady, predictable load and throttles when you exceed it. Either way, a hot partition can be throttled while the table as a whole has room. If you see ProvisionedThroughputExceededException, look at the key distribution before adding capacity. The SDK retries throttling automatically up to a limit, and sustained throttling surfaces as an error you must handle.
Cost follows the access pattern. A read of up to 4 KB is one read unit; a write of up to 1 KB is one write unit. Large items multiply it, which is a reason to keep items small and to store large blobs in object storage with a pointer. Time to live (TTL) on an attribute deletes expired items for free, and it is the usual way to expire sessions and logs.
From access patterns to keys
The method that works is to write down every question the application will ask before drawing a table. For an orders service the list might be: fetch one order by customer and id; list a customer's orders newest first; list all orders in status new for the warehouse; find an order by its public reference. Each becomes a row in a table of access patterns, with the key that serves it:
| Question | Served by |
|---|---|
| One order | Table key pk = customer, sk = date#id with GetItem |
| A customer's recent orders | The same table, Query on pk with a sort key range |
| New orders for the warehouse | A GSI keyed on status, with a sort key of created |
| An order by public reference | A GSI keyed on reference |
The status GSI hides a trap: if almost every order is new when written and then changes, the index partition for new is hot. A common fix is write sharding, a suffix on the index key (new#3) chosen from a small fixed set, with the reader querying all shards and merging. That is the kind of complication that the planning is for, and it is the reason to move patterns that look like reporting out of DynamoDB and into a store built for it.
A GSI is updated asynchronously, so a write to the table is visible in the index a moment later. Never read your own write through a GSI, and size the index's own write capacity, because a throttled index can throttle writes to the table.
Errors worth handling
The SDK returns typed errors that you test with errors.As. The ones that matter:
ConditionalCheckFailedExceptionis the expected failure of a conditional write, and the application's signal for "already exists" or "someone else changed it".ProvisionedThroughputExceededExceptionandThrottlingExceptionmean you exceeded the table's or the account's capacity. The SDK already retried; surface the failure as a retryable error to callers and review the key distribution.TransactionCanceledExceptioncarries a list of cancellation reasons, one per operation, which tells you which condition failed. Read it before retrying.ResourceNotFoundExceptionis a missing table or index, usually an environment mismatch.ValidationExceptionis a malformed request, such as an item over 400 KB, a reserved word used as an attribute name without a placeholder, or an empty string key. Theexpressionpackage and placeholders avoid the reserved word problem, which is why they are used here.
Streams, TTL and keeping data tidy
Two features remove work you would otherwise write. TTL lets you name an attribute holding an epoch time; DynamoDB deletes items after that time at no cost, usually within a day or two, so reads must still filter expired items if exactness matters. DynamoDB Streams publish an ordered record of each change for 24 hours, which feed a Lambda or a consumer for projections, search indexes and notifications; it is the DynamoDB counterpart of the outbox pattern in the outbox article, with the same requirement that consumers tolerate duplicates.
A few practices keep a table healthy over years. Version your items with a schema attribute so readers handle old shapes, because there is no migration command; changes happen lazily or with a scan and rewrite job run with care and throttled. Keep attribute names short if items are numerous, since names are stored with every item and counted in its size. Enable point in time recovery on tables that matter. And tag and monitor: consumed capacity, throttled requests and system errors are the metrics that tell you a table is under pressure before users do.
Testing and local development
DynamoDB Local runs as a container or jar and implements the API closely enough for functional tests of key design, conditions and paginators. It does not reproduce throttling, partition limits or the consistency behaviour of the real service. A typical setup: start it in CI, create the table in test setup with the same definition as production (share it through the infrastructure code), run the tests against BaseEndpoint, and keep a small number of tests against a real AWS account for behaviours the emulator does not model. Test the paginator with more than 1 MB of data once, since it is the bug that does not show up otherwise.
When to choose it
DynamoDB fits workloads with known access patterns by key, very high or spiky scale, a need to run without operations, and tolerance for the modelling discipline: sessions, carts, user profiles, device state, event stores. It is a poor fit for exploratory queries and reports, for data with many relationships and for teams that will want a different question next quarter; a relational database answers new questions with a new query, and DynamoDB answers them with a new table or a migration. Check the current limits and pricing in the AWS pages on best practices for DynamoDB and the SDK for Go v2 guide, and the package documentation on pkg.go.dev.


