hashicorp/terraform · error

failed to retrieve lock info for lock ID

Error message

failed to retrieve lock info for lock ID %q: %s

What it means

Thrown inside unlockWithDynamoDB when the prerequisite getLockInfoWithDynamoDB call fails. Terraform reads the existing lock info from DynamoDB before deleting the row, both to verify ID ownership and to populate lockErr.Info for diagnostics. If the read fails, the delete never happens.

Solutions

  1. Confirm the table exists in-region: `aws dynamodb describe-table --table-name <table>`.
  2. Verify dynamodb:GetItem on the table for the principal.
  3. Inspect the wrapped error in the %s segment to distinguish AccessDenied from ResourceNotFoundException vs. unmarshal failure.
  4. If the Info attribute is corrupted, delete the row directly: `aws dynamodb delete-item --table-name <table> --key '{"LockID":{"S":"<bucket>/<path>"}}'`.
  5. Retry force-unlock after restoring table access; transient throttling clears on backoff.

Example fix

# bypass the failed read-info step by deleting the DDB lock row directly
aws dynamodb delete-item \
  --table-name terraform-locks \
  --key '{"LockID":{"S":"tf-state-prod/prod/terraform.tfstate"}}'
Defensive patterns

Strategy: validation

Validate before calling

// Before unlock, confirm the DDB lock row is readable.
func ddbLockReadable(ctx context.Context, c *dynamodb.Client, table, lockPath string) error {
  _, err := c.GetItem(ctx, &dynamodb.GetItemInput{
    Key: map[string]types.AttributeValue{"LockID": &types.AttributeValueMemberS{Value: lockPath}},
    TableName: &table, ProjectionExpression: aws.String("LockID, Info"),
  })
  return err
}

Try / catch

// If getLockInfoWithDynamoDB fails with ResourceNotFound, the row is already gone (benign).
lockInfo, err := c.getLockInfoWithDynamoDB(ctx)
if err != nil {
  var apiErr smithy.APIError
  if errors.As(err, &apiErr) && apiErr.ErrorCode() == "ResourceNotFoundException" {
    return nil // row already absent
  }
  return fmt.Errorf("failed to retrieve lock info for lock ID %q: %s; "+
    "delete row LockID=%s manually if needed", id, err, c.lockPath())
}

Prevention

When it happens

Trigger: c.getLockInfoWithDynamoDB at client.go:565 returns an error. Triggers: DynamoDB GetItem error (table deleted, AccessDenied on dynamodb:GetItem, throttling, region mismatch), or the JSON unmarshal of the Info attribute fails inside getLockInfoWithDynamoDB.

Common situations: DynamoDB lock table deleted or renamed after the lock was taken, IAM principal lost dynamodb:GetItem, provisioned-capacity throttling on read, the Info attribute corrupted, or cross-account access where the role lacks read on the table.

Related errors


AI-assisted analysis of hashicorp/terraform@d32a084675 (2026-08-11). Data as JSON: /api/errors/dfedd2d54d0871db. Report an issue: GitHub.

Appendix: source

Thrown at internal/backend/remote-state/s3/client.go:567

		Key:    aws.String(c.lockFilePath),
	})

	if err != nil {
		return fmt.Errorf("failed to delete the lock file: %w", err)
	}

	log.Debug(fmt.Sprintf("Deleted lock file: '%q'", c.lockFilePath))

	return nil
}

func (c *RemoteClient) unlockWithDynamoDB(ctx context.Context, id string, lockErr *statemgr.LockError) error {
	// TODO: store the path and lock ID in separate fields, and have proper
	// projection expression only delete the lock if both match, rather than
	// checking the ID from the info field first.
	lockInfo, err := c.getLockInfoWithDynamoDB(ctx)
	if err != nil {
		return fmt.Errorf("failed to retrieve lock info for lock ID %q: %s", id, err)
	}
	lockErr.Info = lockInfo

	if lockInfo.ID != id {
		return fmt.Errorf("lock ID %q does not match existing lock (%q)", id, lockInfo.ID)
	}

	params := &dynamodb.DeleteItemInput{
		Key: map[string]dynamodbtypes.AttributeValue{
			"LockID": &dynamodbtypes.AttributeValueMemberS{
				Value: c.lockPath(),
			},
		},
		TableName: aws.String(c.ddbTable),
	}
	_, err = c.dynClient.DeleteItem(ctx, params)

	if err != nil {

View on GitHub (pinned to d32a084675)