hashicorp/terraform · error

lock ID %q does not match existing lock (%q)

Error message

lock ID %q does not match existing lock (%q)

What it means

Returned by unlockWithDynamoDB after the lock info is read from DynamoDB but the stored lockInfo.ID does not match the id passed to Unlock. This is the DynamoDB equivalent of the S3 ownership check (387) and prevents releasing another client's lock.

Source

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

	}

	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 {
		return err
	}
	return nil
}

View on GitHub (pinned to c9def3e214)

Solutions

  1. Use the most recent lock ID from the current 'Error acquiring the state lock' message.
  2. Query the table to read the live lockInfo.ID: aws dynamodb get-item with ProjectionExpression Info.
  3. Coordinate to avoid concurrent runs against the same workspace.
  4. If the stored lock is definitively orphaned, delete the row manually with the correct LockID key.

Example fix

# before: stale id
terraform force-unlock old-id
# after: read current id then unlock
aws dynamodb get-item --table-name locks --key '{"LockID":{"S":"<bucket>/<path>"}}'
terraform force-unlock <current-id>
Defensive patterns

Strategy: validation

Validate before calling

// Read the live DynamoDB lock ID and compare before unlock
func liveDynamoLockID(ctx context.Context, ddbs *dynamodb.Client, table, lockPath string) (string, error) {
  resp, err := ddbs.GetItem(ctx, &dynamodb.GetItemInput{
    Key: map[string]types.AttributeValue{"LockID": &types.AttributeValueMemberS{Value: lockPath}},
    ProjectionExpression: aws.String("Info"),
    TableName: &table, ConsistentRead: aws.Bool(true),
  })
  if err != nil { return "", err }
  var li statemgr.LockInfo
  if s, ok := resp.Item["Info"].(*types.AttributeValueMemberS); ok {
    if err := json.Unmarshal([]byte(s.Value), &li); err != nil { return "", err }
  }
  return li.ID, nil
}

Try / catch

if err := client.Unlock(id); err != nil {
    if strings.Contains(err.Error(), "does not match existing lock") {
        // re-read live id and re-issue force-unlock
    }
}

Prevention

When it happens

Trigger: Unlock(id) -> unlockWithDynamoDB where lockInfo.ID (from the Info attribute) != id: operator used a stale/wrong force-unlock ID, or another run re-acquired the lock after the current id was captured.

Common situations: Stale 'terraform force-unlock' id from an old error message; multiple CI runners racing on the same workspace; manual edits to the DynamoDB Info attribute.

Related errors


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