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

  1. 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.
  2. Run `tofu force-unlock <correct-id>` with the ID printed by the failed command (the backend echoes the real ID).
  3. If no real lock is active but a stale OTS row remains, delete the row at pkName=c.lockPath() from the tablestore table manually.
  4. 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

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


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)