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
- Thread the exact string returned by Lock() through to the matching Unlock() call without modification.
- Ensure only one Unlock call per Lock; serialize lock/unlock per remoteClient instance.
- If the recorded lockInfo is stale, use the force-unlock path (pass organization/workspace as id) instead of the normal path.
- 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
- Thread the exact string returned by Lock() through to the matching Unlock() call.
- Serialize lock/unlock per remoteClient instance; never share across concurrent operations.
- Never cache/reuse lock IDs across runs.
- If the recorded lock is stale, use the force-unlock path with the canonical org/workspace id.
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
- error uploading state: %v
- %s (lock ID: "%s/%s")
- lock ID %q does not match existing lock ID "%s/%s"
- Error locking state: %s
- failed to lock azure state: %s
AI-assisted analysis of hashicorp/terraform@c9def3e214 (2026-08-07).
Data as JSON: /api/errors/3b31ec431b74d016.
Report an issue: GitHub.