hashicorp/terraform · error

lock ID does not match existing lock

Error message

lock ID does not match existing lock

What it means

Emitted by remoteClient.Unlock (backend_state.go:217-222) on the normal-unlock path where the client holds r.lockInfo but the provided `id` does not equal r.lockInfo.ID. This guards against unlocking with the wrong lock identity — the recorded lock and the requested unlock ID must agree before the Workspaces.Unlock API is called.

Source

Thrown at internal/backend/remote/backend_state.go:220

// Unlock the remote state.
func (r *remoteClient) Unlock(id string) error {
	ctx := context.Background()

	// We first check if there was an error while uploading the latest
	// state. If so, we will not unlock the workspace to prevent any
	// changes from being applied until the correct state is uploaded.
	if r.stateUploadErr {
		return nil
	}

	lockErr := &statemgr.LockError{Info: r.lockInfo}

	// With lock info this should be treated as a normal unlock.
	if r.lockInfo != nil {
		// Verify the expected lock ID.
		if r.lockInfo.ID != id {
			lockErr.Err = fmt.Errorf("lock ID does not match existing lock")
			return lockErr
		}

		// Unlock the workspace.
		// Unlock the workspace.
		err := RetryBackoff(ctx, func() error {
			_, err := r.client.Workspaces.Unlock(ctx, r.workspace.ID)
			if err != nil {
				if errors.Is(err, tfe.ErrWorkspaceLockedStateVersionStillPending) {
					// This is a retryable error.
					return err
				}
				// This will not be retried
				return &errorUnlockFailed{innerError: err}
			}
			return nil
		})

View on GitHub (pinned to c9def3e214)

Solutions

  1. Thread the exact string returned by Lock() through to the matching Unlock() call without modification.
  2. Ensure only one Unlock call per Lock; serialize lock/unlock per remoteClient instance.
  3. If the recorded lockInfo is stale, use the force-unlock path (pass organization/workspace as id) instead of the normal path.
  4. Audit the wrapper code to confirm it does not cache/reuse lock IDs across operations.

Example fix

// before: passing a cached/guessed lock id
lockID := cachedID            // wrong id
err := client.Unlock(lockID)  // "lock ID does not match existing lock"

// after: use the id returned by Lock
lockID, _ := client.Lock(info)
err := client.Unlock(lockID)  // matches r.lockInfo.ID
Defensive patterns

Strategy: validation

Validate before calling

// Validate the unlock id matches the recorded lock before calling the API.
if r.lockInfo != nil && r.lockInfo.ID != id {
    return fmt.Errorf("lock ID does not match existing lock; recorded %q, got %q", r.lockInfo.ID, id)
}

Prevention

When it happens

Trigger: Two parts of the same process (or a wrapper) attempt to unlock with different lock IDs; the lockInfo was populated by a different lock acquisition than the one being released; programmatic misuse passing a stale or fabricated lock ID to Unlock.

Common situations: A custom automation layer caches a lock ID from a previous run and passes it to a new run's unlock; concurrent goroutines unlocking the same remoteClient; mismatch between the ID returned by Lock() and the id passed to Unlock().

Related errors


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