hashicorp/terraform · error

LocalState not locked

Error message

LocalState not locked

What it means

Returned by LocalState.Unlock (local_state.go:209) when s.lockID == "", i.e. the in-process lock tracking field indicates no lock is currently held. Because Unlock is meant to release a previously acquired Lock, calling it on an unlocked state is a programming error. The guard runs under s.mu, so it is race-free within the instance.

Source

Thrown at internal/command/clistate/local_state.go:214

		lockErr := &statemgr.LockError{
			Info: info,
			Err:  err,
		}

		return "", lockErr
	}

	s.lockID = info.ID
	return s.lockID, s.writeLockInfo(info)
}

func (s *LocalState) Unlock(id string) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	if s.lockID == "" {
		return fmt.Errorf("LocalState not locked")
	}

	if id != s.lockID {
		idErr := fmt.Errorf("invalid lock id: %q. current id: %q", id, s.lockID)
		info, err := s.lockInfo()
		if err != nil {
			idErr = errors.Join(idErr, err)
		}

		return &statemgr.LockError{
			Err:  idErr,
			Info: info,
		}
	}

	os.Remove(s.lockInfoPath())

	fileName := s.stateFileOut.Name()

View on GitHub (pinned to c9def3e214)

Solutions

  1. Only call Unlock when Lock returned a non-empty id; capture the id and guard the unlock: `if id != "" { s.Unlock(id) }`.
  2. Use a sentinel (empty string) check before Unlock: `if s.lockID != "" { ... }` — but prefer the captured-id pattern.
  3. Avoid double-unlock by clearing your own reference after Unlock: `id := s.lockID; s.Unlock(id); id = ""`.
  4. In cleanup code, track lock ownership explicitly rather than unconditionally unlocking.

Example fix

// before
id, err := s.Lock(info)
// ... work ...
s.Unlock(id)
s.Unlock(id) // LocalState not locked

// after
id, err := s.Lock(info)
if err != nil { return err }
defer func() { s.Unlock(id); id = "" }()
Defensive patterns

Strategy: validation

Validate before calling

// Only Unlock if we hold a lock
if id == "" || !s.IsLocked() {
    return nil
}
return s.Unlock(id)

Type guard

// IsLocked reports whether this LocalState instance currently holds the in-process lock.
func (s *LocalState) IsLocked() bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    return s.lockID != ""
}

Prevention

When it happens

Trigger: Calling Unlock without a prior successful Lock; calling Unlock twice (double-unlock) because the first call already cleared s.lockID; calling Unlock after Lock failed and the caller assumes a lock exists; cleanup code that unconditionally unlocks regardless of whether Lock succeeded.

Common situations: A deferred Unlock firing after a Lock that errored; `defer s.Unlock("")` left over from a refactor; a backend wrapper that unlocks in a finally block regardless of lock acquisition; test teardown unlocking state that was never locked.

Related errors


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