Skip to main content
Menu
Home WhoAmI Stack Insights Blog Contact
/user/KayD @ karandeepsingh.ca :~$ cat go-dynamodb-crud.md

Go + DynamoDB: Build a Simple CRUD App

Karandeep Singh
• 14 minutes read

Summary

Build a DynamoDB CRUD app in Go step by step. Start raw, hit real pain points, and end up with clean struct-based operations.

Most DynamoDB tutorials dump the final code with attributevalue.MarshalMap and expression builders and expect you to understand why. Instead, we’ll start with the raw, ugly way (manually building attribute maps) so when we switch to structs, you’ll actually appreciate what they’re doing for you.

By the end, you’ll have a working Go app that creates a table, writes items, reads them back, queries efficiently, updates, and deletes.

What We’re Building

A deployment tracker that records which services were deployed, when, and by whom. Simple CRUD, but the patterns apply to any DynamoDB project.

The journey:

  1. Create a table and put an item the raw way
  2. Hit the verbosity wall and switch to structs
  3. Get an item back (the ugly way, then the clean way)
  4. Scan vs Query: learn why Scan is almost always wrong
  5. Update an item
  6. Delete an item and clean up

Prerequisites

  • Go 1.21+ installed
  • AWS CLI configured (aws sts get-caller-identity should work)
  • An AWS account with DynamoDB permissions

Step 1: Create the Table and Put a Raw Item

What: Create a DynamoDB table and insert one item using raw attribute maps.

Why: You need to see the raw SDK types before you can appreciate the shortcuts.

Create your project:

mkdir go-dynamo-crud && cd go-dynamo-crud
go mod init go-dynamo-crud
go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/dynamodb
go get github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue

main.go

package main

import (
	"context"
	"fmt"
	"time"

	"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() {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		panic("configuration error, " + err.Error())
	}
	client := dynamodb.NewFromConfig(cfg)

	tableName := "deployments"

	// Create the table
	_, err = client.CreateTable(context.TODO(), &dynamodb.CreateTableInput{
		TableName: aws.String(tableName),
		AttributeDefinitions: []types.AttributeDefinition{
			{AttributeName: aws.String("ServiceName"), AttributeType: types.ScalarAttributeTypeS},
			{AttributeName: aws.String("DeployedAt"), AttributeType: types.ScalarAttributeTypeS},
		},
		KeySchema: []types.KeySchemaElement{
			{AttributeName: aws.String("ServiceName"), KeyType: types.KeyTypeHash},
			{AttributeName: aws.String("DeployedAt"), KeyType: types.KeyTypeRange},
		},
		BillingMode: types.BillingModePayPerRequest,
	})
	if err != nil {
		panic("failed to create table: " + err.Error())
	}

	// Wait for the table to exist
	waiter := dynamodb.NewTableExistsWaiter(client)
	err = waiter.Wait(context.TODO(), &dynamodb.DescribeTableInput{
		TableName: aws.String(tableName),
	}, 2*time.Minute)
	if err != nil {
		panic("table did not become active: " + err.Error())
	}
	fmt.Println("table created:", tableName)

	// Put an item — the raw way
	_, err = client.PutItem(context.TODO(), &dynamodb.PutItemInput{
		TableName: aws.String(tableName),
		Item: map[string]types.AttributeValue{
			"ServiceName": &types.AttributeValueMemberS{Value: "auth-api"},
			"DeployedAt":  &types.AttributeValueMemberS{Value: "2026-02-14T10:30:00Z"},
			"Version":     &types.AttributeValueMemberS{Value: "v1.4.2"},
			"Status":      &types.AttributeValueMemberS{Value: "success"},
			"DeployedBy":  &types.AttributeValueMemberS{Value: "karandeep"},
		},
	})
	if err != nil {
		panic("failed to put item: " + err.Error())
	}
	fmt.Println("item inserted: auth-api v1.4.2")
}

We have two key schema elements: ServiceName as the partition key (HASH) and DeployedAt as the sort key (RANGE). This lets us store multiple deployments per service, sorted by time. BillingModePayPerRequest means no capacity planning. You pay per read/write.

The NewTableExistsWaiter polls DynamoDB until the table is active. Without this, your PutItem would fail because the table isn’t ready yet.

Run it:

go run main.go

Expected output:

table created: deployments
item inserted: auth-api v1.4.2

It works. But look at that PutItem call: five fields, each wrapped in &types.AttributeValueMemberS{Value: "..."}. Now imagine adding three more items the same way. That’s 20 lines of AttributeValueMemberS boilerplate. Let’s see how bad it gets.

Step 2: Switch to Structs

What: Use attributevalue.MarshalMap to convert Go structs to DynamoDB items.

Why: The raw attribute map approach is verbose and error-prone. Misspell a field name in the map and DynamoDB won’t complain. It’ll just store the wrong key. Structs with dynamodbav tags fix both problems.

Replace your entire main.go:

main.go

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/feature/dynamodb/attributevalue"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
)

type Deployment struct {
	ServiceName string `dynamodbav:"ServiceName"`
	DeployedAt  string `dynamodbav:"DeployedAt"`
	Version     string `dynamodbav:"Version"`
	Status      string `dynamodbav:"Status"`
	DeployedBy  string `dynamodbav:"DeployedBy"`
}

func main() {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		panic("configuration error, " + err.Error())
	}
	client := dynamodb.NewFromConfig(cfg)

	// Table already exists from Step 1 — just insert items
	deploys := []Deployment{
		{ServiceName: "auth-api", DeployedAt: "2026-02-14T14:00:00Z", Version: "v1.4.3", Status: "failed", DeployedBy: "karandeep"},
		{ServiceName: "payment-svc", DeployedAt: "2026-02-14T09:00:00Z", Version: "v2.1.0", Status: "success", DeployedBy: "karandeep"},
		{ServiceName: "payment-svc", DeployedAt: "2026-02-14T16:00:00Z", Version: "v2.1.1", Status: "success", DeployedBy: "karandeep"},
	}

	for _, d := range deploys {
		item, err := attributevalue.MarshalMap(d)
		if err != nil {
			log.Fatalf("failed to marshal %s: %v", d.ServiceName, err)
		}

		_, err = client.PutItem(context.TODO(), &dynamodb.PutItemInput{
			TableName: aws.String("deployments"),
			Item:      item,
		})
		if err != nil {
			log.Fatalf("failed to put %s: %v", d.ServiceName, err)
		}
		fmt.Printf("inserted: %s %s (%s)\n", d.ServiceName, d.Version, d.Status)
	}
	fmt.Printf("done — %d items inserted\n", len(deploys))
}

The dynamodbav struct tag tells attributevalue.MarshalMap how to map each Go field to a DynamoDB attribute name. The struct becomes the single source of truth for your data shape.

Compare the two approaches:

// Raw — verbose, error-prone, no compile-time checks
"ServiceName": &types.AttributeValueMemberS{Value: "auth-api"},
"DeployedAt":  &types.AttributeValueMemberS{Value: "2026-02-14T14:00:00Z"},

// Struct — fill in fields, marshalling is automatic
Deployment{ServiceName: "auth-api", DeployedAt: "2026-02-14T14:00:00Z"}

Run it:

go run main.go

Expected output:

inserted: auth-api v1.4.3 (failed)
inserted: payment-svc v2.1.0 (success)
inserted: payment-svc v2.1.1 (success)
done — 3 items inserted

The table now has 4 items total (1 from Step 1 + 3 from here). Now let’s read them back.

Step 3: Get an Item Back

What: Retrieve a single item by its key.

Why: DynamoDB’s GetItem requires the full primary key, both the partition key and the sort key. If you only give the partition key, you get an error. This trips up everyone coming from SQL databases where you query by any column.

Replace your entire main.go:

main.go

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/feature/dynamodb/attributevalue"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

type Deployment struct {
	ServiceName string `dynamodbav:"ServiceName"`
	DeployedAt  string `dynamodbav:"DeployedAt"`
	Version     string `dynamodbav:"Version"`
	Status      string `dynamodbav:"Status"`
	DeployedBy  string `dynamodbav:"DeployedBy"`
}

func main() {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		panic("configuration error, " + err.Error())
	}
	client := dynamodb.NewFromConfig(cfg)

	// Get an item — you need BOTH the partition key AND the sort key
	result, err := client.GetItem(context.TODO(), &dynamodb.GetItemInput{
		TableName: aws.String("deployments"),
		Key: map[string]types.AttributeValue{
			"ServiceName": &types.AttributeValueMemberS{Value: "auth-api"},
			"DeployedAt":  &types.AttributeValueMemberS{Value: "2026-02-14T10:30:00Z"},
		},
	})
	if err != nil {
		log.Fatal("failed to get item:", err)
	}

	// The RAW way — type-switch on every attribute
	fmt.Println("=== Raw approach ===")
	for key, attr := range result.Item {
		switch v := attr.(type) {
		case *types.AttributeValueMemberS:
			fmt.Printf("  %s: %s\n", key, v.Value)
		case *types.AttributeValueMemberN:
			fmt.Printf("  %s: %s\n", key, v.Value)
		default:
			fmt.Printf("  %s: (unknown type)\n", key)
		}
	}

	// The CLEAN way — UnmarshalMap into a struct
	fmt.Println("\n=== Struct approach ===")
	var deploy Deployment
	err = attributevalue.UnmarshalMap(result.Item, &deploy)
	if err != nil {
		log.Fatal("failed to unmarshal:", err)
	}
	fmt.Printf("  Service:  %s\n", deploy.ServiceName)
	fmt.Printf("  Version:  %s\n", deploy.Version)
	fmt.Printf("  Status:   %s\n", deploy.Status)
	fmt.Printf("  Deployed: %s by %s\n", deploy.DeployedAt, deploy.DeployedBy)
}

Both approaches use the same GetItem call and the same response. The difference is how you read the result. The raw way requires a type-switch for every attribute type (AttributeValueMemberS, AttributeValueMemberN, AttributeValueMemberBOOL…). For five fields it’s annoying. For fifteen it’s a nightmare.

attributevalue.UnmarshalMap is the mirror of MarshalMap. It reads the dynamodbav tags on your struct and maps the DynamoDB attributes back into Go fields. No type-switching needed.

Run it:

go run main.go

Expected output:

=== Raw approach ===
  ServiceName: auth-api
  Status: success
  DeployedBy: karandeep
  DeployedAt: 2026-02-14T10:30:00Z
  Version: v1.4.2

=== Struct approach ===
  Service:  auth-api
  Version:  v1.4.2
  Status:   success
  Deployed: 2026-02-14T10:30:00Z by karandeep

Note the raw output comes in random order. DynamoDB maps are unordered. The struct approach gives you named fields, so you control the display.

Step 4: Scan vs Query

What: Find multiple items, first with Scan (reads everything), then with Query (reads only what matches).

Why: This is the single most important DynamoDB concept. Scan reads every item in the table and filters after. Query uses the partition key to read only matching items. On a table with a million rows, Scan costs you real money. Query doesn’t.

Replace your entire main.go:

main.go

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/feature/dynamodb/attributevalue"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

type Deployment struct {
	ServiceName string `dynamodbav:"ServiceName"`
	DeployedAt  string `dynamodbav:"DeployedAt"`
	Version     string `dynamodbav:"Version"`
	Status      string `dynamodbav:"Status"`
	DeployedBy  string `dynamodbav:"DeployedBy"`
}

func main() {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		panic("configuration error, " + err.Error())
	}
	client := dynamodb.NewFromConfig(cfg)

	// SCAN — find all failed deployments (reads the ENTIRE table)
	fmt.Println("=== Scan: find failed deployments ===")

	scanResult, err := client.Scan(context.TODO(), &dynamodb.ScanInput{
		TableName:        aws.String("deployments"),
		FilterExpression: aws.String("#s = :status"),
		ExpressionAttributeNames: map[string]string{
			"#s": "Status",
		},
		ExpressionAttributeValues: map[string]types.AttributeValue{
			":status": &types.AttributeValueMemberS{Value: "failed"},
		},
	})
	if err != nil {
		log.Fatal("scan failed:", err)
	}

	var failed []Deployment
	attributevalue.UnmarshalListOfMaps(scanResult.Items, &failed)

	fmt.Printf("scanned %d items, found %d failures:\n", scanResult.ScannedCount, len(failed))
	for _, d := range failed {
		fmt.Printf("  FAILED: %s %s at %s\n", d.ServiceName, d.Version, d.DeployedAt)
	}

	// QUERY — get all deployments for auth-api (reads ONLY that partition)
	fmt.Println("\n=== Query: auth-api deployments ===")

	queryResult, err := client.Query(context.TODO(), &dynamodb.QueryInput{
		TableName:              aws.String("deployments"),
		KeyConditionExpression: aws.String("ServiceName = :svc"),
		ExpressionAttributeValues: map[string]types.AttributeValue{
			":svc": &types.AttributeValueMemberS{Value: "auth-api"},
		},
	})
	if err != nil {
		log.Fatal("query failed:", err)
	}

	var authDeploys []Deployment
	attributevalue.UnmarshalListOfMaps(queryResult.Items, &authDeploys)

	fmt.Printf("found %d deployments for auth-api:\n", len(authDeploys))
	for _, d := range authDeploys {
		fmt.Printf("  %s  %s  %s\n", d.DeployedAt, d.Version, d.Status)
	}
}

A few things to unpack here.

Scan: Status is a reserved word in DynamoDB, so you can’t use it directly in expressions. That’s what #s is for. ExpressionAttributeNames replaces #s with the actual attribute name. The ExpressionAttributeValues map replaces :status with the value. DynamoDB requires this placeholder pattern for all expressions.

Query: Uses KeyConditionExpression instead of FilterExpression. This tells DynamoDB to only read items where ServiceName equals auth-api. It doesn’t touch other partitions at all. ServiceName isn’t a reserved word, so we don’t need ExpressionAttributeNames here.

UnmarshalListOfMaps works like UnmarshalMap but for a slice of items.

Run it:

go run main.go

Expected output:

=== Scan: find failed deployments ===
scanned 4 items, found 1 failures:
  FAILED: auth-api v1.4.3 at 2026-02-14T14:00:00Z

=== Query: auth-api deployments ===
found 2 deployments for auth-api:
  2026-02-14T10:30:00Z  v1.4.2  success
  2026-02-14T14:00:00Z  v1.4.3  failed

Notice the scan read all 4 items to find 1 match. The query only read the 2 items in the auth-api partition. On a table with 10 million items, that difference is your AWS bill.

Also notice that Query results come back sorted by the sort key (DeployedAt) automatically. That’s why we chose a timestamp as the sort key: chronological order for free.

The rule: Use Query when you know the partition key. Use Scan only when you genuinely need to search across all partitions (rare in production).

Step 5: Update an Item

What: Change a field on an existing item without replacing the whole thing.

Why: The failed auth-api v1.4.3 deploy got fixed. We want to update its status from “failed” to “success” without rewriting all five fields.

Replace your entire main.go:

main.go

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/feature/dynamodb/attributevalue"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

type Deployment struct {
	ServiceName string `dynamodbav:"ServiceName"`
	DeployedAt  string `dynamodbav:"DeployedAt"`
	Version     string `dynamodbav:"Version"`
	Status      string `dynamodbav:"Status"`
	DeployedBy  string `dynamodbav:"DeployedBy"`
}

func main() {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		panic("configuration error, " + err.Error())
	}
	client := dynamodb.NewFromConfig(cfg)

	serviceName := "auth-api"
	deployedAt := "2026-02-14T14:00:00Z"

	// First, show the current state
	fmt.Println("=== Before update ===")
	before := getItem(client, serviceName, deployedAt)
	fmt.Printf("  %s %s — status: %s\n", before.ServiceName, before.Version, before.Status)

	// Update just the Status field
	_, err = client.UpdateItem(context.TODO(), &dynamodb.UpdateItemInput{
		TableName: aws.String("deployments"),
		Key: map[string]types.AttributeValue{
			"ServiceName": &types.AttributeValueMemberS{Value: serviceName},
			"DeployedAt":  &types.AttributeValueMemberS{Value: deployedAt},
		},
		UpdateExpression: aws.String("SET #s = :status"),
		ExpressionAttributeNames: map[string]string{
			"#s": "Status",
		},
		ExpressionAttributeValues: map[string]types.AttributeValue{
			":status": &types.AttributeValueMemberS{Value: "success"},
		},
	})
	if err != nil {
		log.Fatal("update failed:", err)
	}
	fmt.Println("\nstatus updated to 'success'")

	// Verify the update
	fmt.Println("\n=== After update ===")
	after := getItem(client, serviceName, deployedAt)
	fmt.Printf("  %s %s — status: %s\n", after.ServiceName, after.Version, after.Status)
}

func getItem(client *dynamodb.Client, serviceName, deployedAt string) Deployment {
	result, err := client.GetItem(context.TODO(), &dynamodb.GetItemInput{
		TableName: aws.String("deployments"),
		Key: map[string]types.AttributeValue{
			"ServiceName": &types.AttributeValueMemberS{Value: serviceName},
			"DeployedAt":  &types.AttributeValueMemberS{Value: deployedAt},
		},
	})
	if err != nil {
		log.Fatal("get failed:", err)
	}

	var deploy Deployment
	attributevalue.UnmarshalMap(result.Item, &deploy)
	return deploy
}

The UpdateExpression uses SET to change specific attributes. You always need the full primary key (both partition and sort key) to identify which item to update. You can set multiple fields at once: SET #s = :status, Version = :ver.

Run it:

go run main.go

Expected output:

=== Before update ===
  auth-api v1.4.3 — status: failed

status updated to 'success'

=== After update ===
  auth-api v1.4.3 — status: success

The update only changed the Status field. The other four fields (ServiceName, DeployedAt, Version, DeployedBy) are untouched.

Step 6: Delete an Item and Clean Up

What: Remove an item from the table, then delete the table itself.

Why: Old deployments need cleanup. Delete works like GetItem: you provide the full primary key. And when we’re done experimenting, we delete the table so we’re not paying for it.

Replace your entire main.go:

main.go

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/feature/dynamodb/attributevalue"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

type Deployment struct {
	ServiceName string `dynamodbav:"ServiceName"`
	DeployedAt  string `dynamodbav:"DeployedAt"`
	Version     string `dynamodbav:"Version"`
	Status      string `dynamodbav:"Status"`
	DeployedBy  string `dynamodbav:"DeployedBy"`
}

func main() {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		panic("configuration error, " + err.Error())
	}
	client := dynamodb.NewFromConfig(cfg)

	// Show what we have before deleting
	fmt.Println("=== Before delete ===")
	authDeploys := queryService(client, "auth-api")
	fmt.Printf("auth-api has %d deployments\n", len(authDeploys))
	for _, d := range authDeploys {
		fmt.Printf("  %s  %s  %s\n", d.DeployedAt, d.Version, d.Status)
	}

	// Delete the oldest auth-api deployment
	_, err = client.DeleteItem(context.TODO(), &dynamodb.DeleteItemInput{
		TableName: aws.String("deployments"),
		Key: map[string]types.AttributeValue{
			"ServiceName": &types.AttributeValueMemberS{Value: "auth-api"},
			"DeployedAt":  &types.AttributeValueMemberS{Value: "2026-02-14T10:30:00Z"},
		},
	})
	if err != nil {
		log.Fatal("delete failed:", err)
	}
	fmt.Println("\ndeleted auth-api deploy from 10:30")

	// Verify it's gone
	fmt.Println("\n=== After delete ===")
	authDeploys = queryService(client, "auth-api")
	fmt.Printf("auth-api now has %d deployments\n", len(authDeploys))
	for _, d := range authDeploys {
		fmt.Printf("  %s  %s  %s\n", d.DeployedAt, d.Version, d.Status)
	}

	// Clean up — delete the table
	_, err = client.DeleteTable(context.TODO(), &dynamodb.DeleteTableInput{
		TableName: aws.String("deployments"),
	})
	if err != nil {
		log.Fatal("failed to delete table:", err)
	}
	fmt.Println("\ntable 'deployments' deleted — cleanup complete")
}

func queryService(client *dynamodb.Client, serviceName string) []Deployment {
	result, err := client.Query(context.TODO(), &dynamodb.QueryInput{
		TableName:              aws.String("deployments"),
		KeyConditionExpression: aws.String("ServiceName = :svc"),
		ExpressionAttributeValues: map[string]types.AttributeValue{
			":svc": &types.AttributeValueMemberS{Value: serviceName},
		},
	})
	if err != nil {
		log.Fatal("query failed:", err)
	}

	var deploys []Deployment
	attributevalue.UnmarshalListOfMaps(result.Items, &deploys)
	return deploys
}

Run it:

go run main.go

Expected output:

=== Before delete ===
auth-api has 2 deployments
  2026-02-14T10:30:00Z  v1.4.2  success
  2026-02-14T14:00:00Z  v1.4.3  success

deleted auth-api deploy from 10:30

=== After delete ===
auth-api now has 1 deployments
  2026-02-14T14:00:00Z  v1.4.3  success

table 'deployments' deleted — cleanup complete

No confirmation prompt, no soft delete. The item is gone immediately. DynamoDB doesn’t have a recycle bin. If you need to recover deleted items in production, enable Point-in-Time Recovery on the table before deleting anything.

What We Built

Starting from raw attribute maps that nobody wants to maintain, we incrementally built:

  1. Table creation: composite key (partition + sort) with a waiter
  2. PutItem with structs: MarshalMap converts Go structs to DynamoDB items
  3. GetItem with structs: UnmarshalMap converts DynamoDB items back to Go structs
  4. Scan vs Query: why Query is almost always the right choice
  5. UpdateItem: change specific fields with SET expressions
  6. DeleteItem: remove by primary key

The pattern for every DynamoDB operation is the same: load config, create client, build input struct, call the method, unmarshal the result. Once you’ve seen it for PutItem, you’ve seen it for everything.

Next Steps

This covers single-table CRUD. In a real project, you’d add:

  • Batch writes with BatchWriteItem for bulk inserts
  • Pagination for large Query results (check result.LastEvaluatedKey)
  • Conditional writes to prevent overwriting existing items
  • GSIs (Global Secondary Indexes) to query by non-key attributes like Status

Check out Building a Go Lambda Function to put this DynamoDB code behind an API endpoint, Building a Go S3 CLI Tool for more Go + AWS SDK patterns, or Terraform From Scratch to provision DynamoDB tables as code.

Cheat Sheet

Copy-paste reference for when you just need the syntax.

Setup:

cfg, _ := config.LoadDefaultConfig(context.TODO())
client := dynamodb.NewFromConfig(cfg)

Struct with tags:

type Item struct {
    PK    string `dynamodbav:"PK"`
    SK    string `dynamodbav:"SK"`
    Name  string `dynamodbav:"Name"`
}

Put item:

item, _ := attributevalue.MarshalMap(myStruct)
client.PutItem(ctx, &dynamodb.PutItemInput{TableName: aws.String("table"), Item: item})

Get item:

result, _ := client.GetItem(ctx, &dynamodb.GetItemInput{
    TableName: aws.String("table"),
    Key: map[string]types.AttributeValue{
        "PK": &types.AttributeValueMemberS{Value: "value"},
    },
})
var item Item
attributevalue.UnmarshalMap(result.Item, &item)

Query by partition key:

result, _ := client.Query(ctx, &dynamodb.QueryInput{
    TableName:              aws.String("table"),
    KeyConditionExpression: aws.String("PK = :pk"),
    ExpressionAttributeValues: map[string]types.AttributeValue{
        ":pk": &types.AttributeValueMemberS{Value: "value"},
    },
})
var items []Item
attributevalue.UnmarshalListOfMaps(result.Items, &items)

Update one field:

client.UpdateItem(ctx, &dynamodb.UpdateItemInput{
    TableName: aws.String("table"),
    Key:       key,  // same format as GetItem
    UpdateExpression: aws.String("SET #f = :val"),
    ExpressionAttributeNames:  map[string]string{"#f": "FieldName"},
    ExpressionAttributeValues: map[string]types.AttributeValue{":val": &types.AttributeValueMemberS{Value: "new"}},
})

Delete item:

client.DeleteItem(ctx, &dynamodb.DeleteItemInput{TableName: aws.String("table"), Key: key})

Key rules to remember:

  • GetItem, UpdateItem, DeleteItem all need the full primary key (partition + sort if you have one)
  • Query needs at least the partition key. Use it when you know which partition to look in
  • Scan reads the entire table. Avoid in production, use only for debugging or small tables
  • Status, Name, Size, Count are reserved words. Use #placeholder in expressions
  • MarshalMap turns a Go struct into DynamoDB format, UnmarshalMap does the reverse
  • Always use PayPerRequest billing for dev tables. No capacity planning needed
Contents