hashicorp/terraform · error
lock id does not match existing lock
Error message
lock id %q does not match existing lock
What it means
Thrown by RemoteClient.Unlock in the Alibaba Cloud OSS state backend when the lock ID supplied to unlock does not equal the ID stored in Tablestore (OTS) for this state. The backend refuses to delete the lock row because the caller is not the lock owner, preventing one process from clobbering another process's lock. The returned value is a *statemgr.LockError carrying the real (existing) lock info, so the caller can report who actually holds it.
Solutions
- Inspect the *statemgr.LockError.Info field returned - it holds the actual current LockInfo (ID, Who, Created, Operation); use that ID, not the one you passed.
- Run `tofu force-unlock <correct-id>` with the ID printed by the failed command (the backend echoes the real ID).
- If no real lock is active but a stale OTS row remains, delete the row at pkName=c.lockPath() from the tablestore table manually.
- Ensure only one OpenTofu process operates on the workspace concurrently to avoid ID races.
Example fix
// before err := client.Unlock(staleID) // after info, _ := client.getLockInfo() err := client.Unlock(info.ID)
Defensive patterns
Strategy: validation
Validate before calling
// before unlocking, fetch the current lock info and compare IDs
info, err := client.getLockInfo()
if err != nil {
return err
}
if info.ID != id {
return fmt.Errorf("refusing to unlock: passed %q but stored lock is %q held by %s; use force-unlock %s", id, info.ID, info.Who, info.ID)
} Type guard
func isLockIDMismatchErr(err error) bool {
var le *statemgr.LockError
return errors.As(err, &le) && le.Err != nil && strings.Contains(le.Err.Error(), "does not match existing lock")
} Try / catch
err := client.Unlock(id)
var le *statemgr.LockError
if errors.As(err, &le) && le.Info != nil {
// surface the real holder instead of the wrong-id message
log.Printf("lock held by %s since %s; correct id %s", le.Info.Who, le.Info.Created, le.Info.ID)
} Prevention
- Always read the lock ID via getLockInfo() or the CLI output rather than hardcoding/caching it.
- Pass *statemgr.LockError.Info up to the user so the correct ID is visible.
- Never store lock IDs across runs - they are per-lock.
When it happens
Trigger: Calling Unlock(id) with an ID that differs from lockInfo.ID read via getLockInfo() from the OTS row at c.lockPath(). Happens when a stale lock ID from a previous run is reused, when force-unlock is attempted with a guessed/wrong ID, or when two writers race and one holds a different lock.
Common situations: A prior `tofu apply` crashed leaving a dangling lock; the operator copies the wrong lock ID from logs; CI reuses a cached lock ID; a teammate force-unlocked and re-locked between your read and unlock.
Related errors
- Already locked for workspace creation
- Cannot lock workspace; already locked for workspace creation
- error getting object: %#v
- Error locking state
- estimating object is exist got an error: %#v
AI-assisted analysis of hashicorp/terraform@d32a084675 (2026-08-11).
Data as JSON: /api/errors/cec5d8fab78d21b8.
Report an issue: GitHub.
Appendix: source
Thrown at internal/backend/remote-state/oss/client.go:379
}
return lockInfo, nil
}
func (c *RemoteClient) Unlock(id string) error {
if c.otsTable == "" {
return nil
}
lockErr := &statemgr.LockError{}
lockInfo, err := c.getLockInfo()
if err != nil {
lockErr.Err = fmt.Errorf("failed to retrieve lock info: %s", err)
return lockErr
}
lockErr.Info = lockInfo
if lockInfo.ID != id {
lockErr.Err = fmt.Errorf("lock id %q does not match existing lock", id)
return lockErr
}
params := &tablestore.DeleteRowRequest{
DeleteRowChange: &tablestore.DeleteRowChange{
TableName: c.otsTable,
PrimaryKey: &tablestore.PrimaryKey{
PrimaryKeys: []*tablestore.PrimaryKeyColumn{
{
ColumnName: pkName,
Value: c.lockPath(),
},
},
},
Condition: &tablestore.RowCondition{
RowExistenceExpectation: tablestore.RowExistenceExpectation_IGNORE,
},
},
}View on GitHub (pinned to d32a084675)