How to Use AWS DynamoDB from Go

Initialize a DynamoDB client in Go using the AWS SDK v2 with your region and default credentials.

The setup

You are building a session store or a real-time leaderboard. You need single-digit millisecond reads, predictable write latency, and zero index maintenance. You pick DynamoDB. Now you need to wire it into Go without drowning in boilerplate or fighting the type system.

DynamoDB is a key-value and document store. You do not write SQL. You do not join tables. You hand it a partition key, and it returns the item or tells you it is missing. The AWS SDK for Go v2 handles the HTTP transport, retries, and signature calculation. Your job is to pass the right context, map Go structs to DynamoDB types, and handle errors explicitly.

How the client actually works

The SDK does not connect to a database server. It builds an HTTP client that signs requests with AWS credentials and routes them to the DynamoDB API endpoint. The client itself is thread-safe and cheap to create. You initialize it once, usually at startup, and share it across handlers.

Here is the minimal client setup:

package main

import (
	"context"
	"fmt"
	"log"

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

func main() {
	// Load credentials from environment, IAM role, or shared config file
	// Region is required for DynamoDB endpoint resolution
	cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion("us-east-1"))
	if err != nil {
		log.Fatalf("failed to load AWS config: %v", err)
	}

	// Create the DynamoDB client from the loaded configuration
	// The client holds an HTTP transport and retryer, safe for concurrent use
	client := dynamodb.NewFromConfig(cfg)

	// Verify connectivity by calling a lightweight metadata endpoint
	_, err = client.ListTables(context.Background(), &dynamodb.ListTablesInput{})
	if err != nil {
		log.Fatalf("DynamoDB unreachable: %v", err)
	}

	fmt.Println("DynamoDB client ready")
}

The config.LoadDefaultConfig call walks a standard chain. It checks environment variables first, then falls back to the ~/.aws/credentials file, then to the IAM instance profile if you are running on EC2 or ECS. If nothing is found, it returns an error. The SDK does not guess. You get failed to refresh credentials: no EC2 IMDS role found, or similar when the chain breaks.

The client object carries the region, credentials, and retry configuration. It does not hold open connections to a specific table. DynamoDB is a REST API. Every method call builds a fresh request, signs it, and sends it over HTTP. This design means you can safely pass the same client to ten different HTTP handlers without locking or race conditions.

Context is plumbing. Run it through every long-lived call site.

Talking to the table

DynamoDB operations are explicit. You do not query with SELECT. You call GetItem, PutItem, UpdateItem, or Query. Each operation takes a context and an input struct. The SDK returns an output struct and an error.

Here is a write operation that stores a user session:

func storeSession(client *dynamodb.Client, sessionID string, userID string, expiresAt time.Time) error {
	// Build the item map that DynamoDB expects
	// DynamoDB requires typed attribute values, not raw Go primitives
	item := map[string]types.AttributeValue{
		"SessionID": &types.AttributeValueMemberS{Value: sessionID},
		"UserID":    &types.AttributeValueMemberS{Value: userID},
		"ExpiresAt": &types.AttributeValueMemberN{Value: fmt.Sprintf("%d", expiresAt.Unix())},
	}

	// Execute the write with a timeout context
	// Context cancellation stops the HTTP request and releases resources
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	_, err := client.PutItem(ctx, &dynamodb.PutItemInput{
		TableName: aws.String("sessions"),
		Item:      item,
	})
	// Return the error directly
	// The caller decides whether to retry, log, or abort
	return err
}

The PutItem call sends a JSON payload to the DynamoDB endpoint. The service validates the partition key, checks provisioned capacity or on-demand limits, and writes to the primary index. If the table does not exist, you get ResourceNotFoundException: Requested resource not found. If you exceed your write capacity, you get ProvisionedThroughputExceededException. The SDK's built-in retryer handles transient throttling automatically. You only see the error if retries are exhausted.

Go convention dictates that context.Context always goes as the first parameter. Functions that take a context should respect cancellation and deadlines. The SDK enforces this pattern across every operation.

Bridging Go types and DynamoDB types

DynamoDB does not understand Go structs. It understands a specific JSON-like wire format called AttributeValue. Every field must be wrapped in a typed member: AttributeValueMemberS for strings, AttributeValueMemberN for numbers, AttributeValueMemberB for binary data. Writing these wrappers by hand is tedious and error-prone.

The SDK provides a helper package called dynamodbattribute. It marshals Go structs into DynamoDB maps and unmarshals them back. This is where the "accept interfaces, return structs" mantra pays off. You define plain Go structs for your domain, and the helper handles the translation.

Here is how you read an item back using the marshaler:

type Session struct {
	SessionID string `dynamodbav:"SessionID"`
	UserID    string `dynamodbav:"UserID"`
	ExpiresAt int64  `dynamodbav:"ExpiresAt"`
}

func getSession(client *dynamodb.Client, sessionID string) (*Session, error) {
	// Build the key map for the partition key
	// DynamoDB requires exact key type matching for lookups
	key := map[string]types.AttributeValue{
		"SessionID": &types.AttributeValueMemberS{Value: sessionID},
	}

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	// Fetch the item from the table
	// Returns nil item if the key does not exist
	result, err := client.GetItem(ctx, &dynamodb.GetItemInput{
		TableName: aws.String("sessions"),
		Key:       key,
	})
	if err != nil {
		return nil, fmt.Errorf("get item failed: %w", err)
	}

	// Handle missing items gracefully
	// DynamoDB returns success with an empty Item map when the key is absent
	if result.Item == nil {
		return nil, nil
	}

	// Convert the raw DynamoDB map into a typed Go struct
	// The helper reads struct tags to map fields correctly
	var session Session
	err = dynamodbattribute.UnmarshalMap(result.Item, &session)
	if err != nil {
		return nil, fmt.Errorf("unmarshal failed: %w", err)
	}

	return &session, nil
}

The dynamodbav struct tags tell the marshaler which DynamoDB attribute name maps to which Go field. If you omit the tag, the marshaler uses the field name as-is. If the types do not match, the unmarshaler fails with SerializationError: number cannot be unmarshalled as string. Always verify your tags match the table schema.

The receiver name convention applies here too. If you were wrapping this in a repository struct, you would name the receiver (s *SessionRepo), not (this *SessionRepo). Keep it short. Match the type initial.

When things go wrong

DynamoDB errors fall into three buckets: configuration, schema, and capacity. Configuration errors surface during client setup or the first request. Schema errors appear when you query with the wrong key type or miss a required attribute. Capacity errors trigger when you exceed provisioned limits or hit account-level throttling.

If you forget to pass a context, the compiler rejects the program with not enough arguments in call to client.GetItem. The SDK methods require it. If you pass a *string where a string is expected, you get cannot use x (type *string) as string value in argument. Go does not auto-dereference. Use aws.String("value") to create pointers, or pass values directly when the SDK accepts them.

Runtime panics are rare with the SDK. Most failures return errors. The worst goroutine bug is the one that never logs. Always check err != nil immediately after SDK calls. The community accepts the boilerplate because it makes the unhappy path visible. Do not swallow errors with _ unless you have a documented reason. Even then, log the discard.

Pagination is another trap. Query and Scan return at most 1 MB of data per call. If your result set is larger, the response includes a LastEvaluatedKey. You must pass that key back in the next request to fetch the next page. The SDK provides QueryPages and ScanPages helpers that loop until the key is nil. If you ignore pagination, you silently drop data.

Goroutine leaks happen when you spawn a background worker that waits on a channel or context that never resolves. Always attach a context with a deadline to long-running SDK calls. Cancel it when the HTTP handler finishes. The SDK respects context cancellation and aborts in-flight requests.

Trust gofmt. Argue logic, not formatting. Run the linter. Let the toolchain enforce consistency while you focus on the data flow.

Choosing your storage path

DynamoDB excels at predictable latency and horizontal scale. It does not excel at complex joins, ad-hoc filtering, or analytical queries. Pick the right tool for the workload.

Use DynamoDB when you need single-digit millisecond reads and writes with a known access pattern. Use DynamoDB when your data fits a partition-key and sort-key model and you want to avoid index maintenance. Use PostgreSQL when you need relational joins, complex aggregations, or ACID transactions across multiple tables. Use a simple file store or embedded database when your dataset fits in memory and you want zero infrastructure overhead. Use the AWS SDK directly when you need fine-grained control over retries, request signing, or low-level attribute mapping. Use a higher-level wrapper or ORM when your team prioritizes developer velocity over explicit error handling and type transparency.

The simplest thing that works is usually the right thing. Start with the SDK. Add abstractions only when the boilerplate actually blocks progress.

Where to go next