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
- Only call Unlock when Lock returned a non-empty id; capture the id and guard the unlock: `if id != "" { s.Unlock(id) }`.
- Use a sentinel (empty string) check before Unlock: `if s.lockID != "" { ... }` — but prefer the captured-id pattern.
- Avoid double-unlock by clearing your own reference after Unlock: `id := s.lockID; s.Unlock(id); id = ""`.
- 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
- Capture the lock id from Lock and only Unlock when it is non-empty.
- In cleanup paths, guard Unlock with the id check.
- Clear your local id reference after Unlock to prevent double-unlock.
- Never Unlock from a goroutine that did not perform the Lock.
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
- state %q already locked
- invalid lock id: %q. current id: %q
- failed to lock azure state: %s
- state blob is already locked
- failed to lock state in Consul: %s
AI-assisted analysis of hashicorp/terraform@c9def3e214 (2026-08-07).
Data as JSON: /api/errors/9beddf23514cb470.
Report an issue: GitHub.