How to Connect to DynamoDB from Go

Connect to DynamoDB from Go by initializing the AWS SDK v2 client and calling GetItem with your table name and primary key.

You have a table, a key, and a blank file

You have a DynamoDB table running in AWS. Your Go service needs to fetch a user profile by ID. You installed the AWS SDK v2, opened your editor, and stared at a blank main.go. The documentation shows Java examples. The Go examples are scattered across three different GitHub repos. You need to make a request, handle the response, and not leak memory while doing it.

The path forward is straightforward. You load a configuration, create a client, and send an operation. The SDK handles the networking, signature signing, and type translation. Your job is to structure the input correctly and process the output.

The SDK design

AWS SDK v2 is modular. You import github.com/aws/aws-sdk-go-v2/config for setup and github.com/aws/aws-sdk-go-v2/service/dynamodb for the service client. This keeps your binary small. You don't pull in S3 or Lambda code if you don't use them. The config package handles credential discovery. The service/dynamodb package contains the client and types. The feature/dynamodb/attributevalue package provides helpers for marshaling and unmarshaling structs. This separation is intentional. It encourages you to compose only what you need.

DynamoDB is a key-value store. You give it a key, it gives you the value. The Go SDK abstracts the HTTP calls and signature signing. You load a configuration, create a client, and send operations. Everything flows through a context.Context to control timeouts and cancellation. The context.Context always goes as the first parameter to SDK methods, conventionally named ctx. Functions that take a context should respect cancellation and deadlines.

Minimal example

Here's the bare minimum to fetch an item. Load config, create client, call GetItem.

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() {
	// LoadDefaultConfig discovers credentials from environment, shared files, or instance metadata.
	cfg, err := config.LoadDefaultConfig(context.Background())
	if err != nil {
		log.Fatalf("config error: %v", err)
	}

	// NewFromConfig binds the client to the region and auth provider from cfg.
	client := dynamodb.NewFromConfig(cfg)

	// Key attributes must be wrapped in types.StringValue to satisfy the AttributeValue interface.
	input := &dynamodb.GetItemInput{
		TableName: aws.String("Users"),
		Key: map[string]types.AttributeValue{
			"pk": &types.StringValue{Value: "user-123"},
		},
	}

	// GetItem executes the request. Pass context.Background() for a simple top-level call.
	result, err := client.GetItem(context.Background(), input)
	if err != nil {
		log.Fatalf("dynamodb error: %v", err)
	}

	// result.Item is a map[string]types.AttributeValue. Check existence before accessing.
	if result.Item != nil {
		fmt.Printf("Item exists: %v\n", result.Item)
	}
}

DynamoDB clients are safe for concurrent use. Create one and share it across goroutines.

How the request flows

LoadDefaultConfig returns an aws.Config. This struct holds the region, a credential provider chain, and an HTTP client. The credential provider chain tries multiple sources. It checks environment variables like AWS_ACCESS_KEY_ID first. Then it looks for a shared credentials file at ~/.aws/credentials. Then it queries the EC2 instance metadata service. If all sources fail, it returns an error.

NewFromConfig creates the client. The client stores the config. When you call GetItem, the client builds an HTTP request. It signs the request using the credentials from the config. The signature includes the payload hash and timestamp. This ensures the request is authentic and fresh. The client sends the request and parses the JSON response into Go types.

The compiler rejects code where you pass a raw string to a *string field with cannot use "literal" (untyped string constant) as *string value in field. You must use aws.String to get a pointer. The types.AttributeValue interface lets DynamoDB support numbers, strings, sets, and nulls in the same map. The client handles retries and rate limiting automatically.

Realistic example: Unmarshaling

Real applications don't print maps. They unmarshal data into structs. Here's a function that fetches a user and converts the response.

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"
)

// User represents the domain model. Tags control how the SDK maps fields.
type User struct {
	ID   string `dynamodbav:"pk"`
	Name string `dynamodbav:"name"`
	Age  int    `dynamodbav:"age"`
}

func getUser(ctx context.Context, client *dynamodb.Client, userID string) (*User, error) {
	// Build the key map. The partition key "pk" determines the row.
	key := map[string]types.AttributeValue{
		"pk": &types.StringValue{Value: userID},
	}

	// GetItem returns the raw attribute map.
	result, err := client.GetItem(ctx, &dynamodb.GetItemInput{
		TableName: aws.String("Users"),
		Key:       key,
	})
	if err != nil {
		return nil, fmt.Errorf("get item: %w", err)
	}

	// If Item is nil, the key doesn't exist. This is not an error.
	if result.Item == nil {
		return nil, nil
	}

	// UnmarshalMap converts the generic map into a typed struct.
	var user User
	err = attributevalue.UnmarshalMap(result.Item, &user)
	if err != nil {
		return nil, fmt.Errorf("unmarshal: %w", err)
	}

	return &user, nil
}

The dynamodbav struct tags tell the SDK which DynamoDB attribute maps to which Go field. If you omit a tag, the SDK uses the field name. Error wrapping with %w preserves the error chain for debugging.

Unmarshal on the read path. Marshal on the write path. Keep your domain structs clean.

Writing data

Writing follows the same pattern. You marshal a struct into a map, then call PutItem.

func putUser(ctx context.Context, client *dynamodb.Client, user *User) error {
	// MarshalMap converts the struct into a map of AttributeValues.
	item, err := attributevalue.MarshalMap(user)
	if err != nil {
		return fmt.Errorf("marshal: %w", err)
	}

	// PutItem writes the item. It overwrites existing data with the same key.
	_, err = client.PutItem(ctx, &dynamodb.PutItemInput{
		TableName: aws.String("Users"),
		Item:      item,
	})
	if err != nil {
		return fmt.Errorf("put item: %w", err)
	}

	return nil
}

attributevalue.MarshalMap handles type conversion. It turns Go int into types.NumberValue and Go string into types.StringValue. This saves you from manual map construction.

Pitfalls and errors

Type mismatches cause runtime issues. DynamoDB stores numbers as strings in the wire format. types.NumberValue holds a string. If you pass a Go int directly to the key map, the compiler rejects it with cannot use 42 (untyped int constant) as types.AttributeValue value in map literal. You must use types.NumberValue{Value: "42"} or rely on attributevalue.Marshal.

GetItem returns nil for the item if the key doesn't exist. It does not return an error. You must check result.Item == nil. If you assume an error, you'll treat missing data as a failure.

Context propagation prevents leaks. Always pass context.Context to SDK calls. The SDK respects cancellation. If the context is cancelled, the request stops. This prevents goroutine leaks in long-running services. Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path.

The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Don't hide errors.

Check result.Item for nil. A missing item is a valid response, not a failure.

Decision matrix

Use GetItem when you have the exact partition key and sort key for a single record. Use Query when you need to filter by partition key and scan a range of sort keys. Use Scan when you must iterate over the entire table, though this is expensive and should be rare. Use attributevalue.Marshal when writing structs to DynamoDB to avoid manual map construction. Use attributevalue.UnmarshalMap when reading items to convert the wire format into Go structs. Use config.WithRegion when you need to override the default region for a specific client.

Query by key. Scan only when you have no choice. Unmarshal to structs.

Where to go next