hashicorp/terraform · error · statemgr.LockError

HTTP remote state already locked: ID=%s

Error message

HTTP remote state already locked: ID=%s

What it means

The legitimate 'state is already locked' outcome: the server returned 409/423, the body parsed, and the holder's lock ID is reported. This is not a bug — it means another terraform process (or a crashed one that left a stale lock) currently holds the state lock. Returned as a statemgr.LockError carrying the parsed LockInfo of the holder.

Source

Thrown at internal/backend/remote-state/http/client.go:119

		return "", fmt.Errorf("HTTP remote state endpoint invalid auth")
	case http.StatusConflict, http.StatusLocked:
		defer resp.Body.Close()
		body, err := io.ReadAll(resp.Body)
		if err != nil {
			return "", &statemgr.LockError{
				Err: fmt.Errorf("HTTP remote state already locked, failed to read body"),
			}
		}
		existing := statemgr.LockInfo{}
		err = json.Unmarshal(body, &existing)
		if err != nil {
			return "", &statemgr.LockError{
				Err: fmt.Errorf("HTTP remote state already locked, failed to unmarshal body"),
			}
		}
		return "", &statemgr.LockError{
			Info: &existing,
			Err:  fmt.Errorf("HTTP remote state already locked: ID=%s", existing.ID),
		}
	default:
		return "", fmt.Errorf("Unexpected HTTP response code %d", resp.StatusCode)
	}
}

func (c *httpClient) Unlock(id string) error {
	if c.UnlockURL == nil {
		return nil
	}

	resp, err := c.httpRequest(c.UnlockMethod, c.UnlockURL, &c.jsonLockInfo, "unlock")
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	switch resp.StatusCode {

View on GitHub (pinned to c9def3e214)

Solutions

  1. Confirm the holder (ID, Who, Operation from the error) is not an active run you should wait for.
  2. If the holder is a dead/crashed run, run `terraform force-unlock <ID>` using the ID printed in the message.
  3. Add pre-run coordination (CI queueing, branch protection) to avoid concurrent applies on shared state.

Example fix

# once you confirm the holder is stale:
terraform force-unlock <ID-printed-in-the-error>
Defensive patterns

Strategy: try-catch

Type guard

func isAlreadyLocked(err error) (id string, ok bool) {
  var le *statemgr.LockError
  if !errors.As(err, &le) || le.Err == nil { return "", false }
  s := le.Err.Error()
  if !strings.Contains(s, "already locked: ID=") { return "", false }
  return strings.TrimPrefix(s, "...ID="), true // adjust prefix to actual message
}

Try / catch

err = sm.Lock(info)
if id, ok := isAlreadyLocked(err); ok {
  if holderIsActive(id) { waitThenRetry() } else { promptForceUnlock(id) }
  return
}

Prevention

When it happens

Trigger: Two engineers (or CI + engineer) running terraform apply against the same state simultaneously; a previous run crashed without unlocking; a long-running apply holding the lock. Fires at lock acquisition during plan/apply/destroy.

Common situations: Shared state with no lock coordination; killed CI run leaving a stale lock; interactive apply still running when a scheduled pipeline starts.

Related errors


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