hashicorp/terraform · error
state %q already locked
Error message
state %q already locked
What it means
Returned by LocalState.Lock (local_state.go:177) when an in-process lock is already held (s.lockID != ""). LocalState uses both an in-memory mutex (s.mu) and an on-disk lock file to coordinate access; this specific guard prevents the same LocalState instance from being locked twice without an intervening Unlock. The stateFileOut.Name() is included so the caller knows which state file is contested.
Source
Thrown at internal/command/clistate/local_state.go:188
s.state = state
s.readState = s.state.DeepCopy()
return nil
}
// Lock implements a local filesystem state.Locker.
func (s *LocalState) Lock(info *statemgr.LockInfo) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.stateFileOut == nil {
if err := s.createStateFiles(); err != nil {
return "", err
}
}
if s.lockID != "" {
return "", fmt.Errorf("state %q already locked", s.stateFileOut.Name())
}
if err := s.lock(); err != nil {
info, infoErr := s.lockInfo()
if infoErr != nil {
err = errors.Join(err, infoErr)
}
lockErr := &statemgr.LockError{
Info: info,
Err: err,
}
return "", lockErr
}
s.lockID = info.ID
return s.lockID, s.writeLockInfo(info)View on GitHub (pinned to c9def3e214)
Solutions
- Ensure every Lock is paired with a deferred Unlock so the same LocalState instance is never locked twice: `id, err := s.Lock(info); defer s.Unlock(id)`.
- Before re-locking, check the returned lock id — if non-empty, call Unlock first.
- Restructure retry logic to unlock fully before re-attempting Lock.
- If sharing a LocalState across goroutines, serialize access through a single owner rather than locking from multiple goroutines.
Example fix
// before
id, _ := s.Lock(info)
// ... error path forgets to unlock ...
id2, err := s.Lock(info) // state "terraform.tfstate" already locked
// after
id, err := s.Lock(info)
if err != nil { return err }
defer s.Unlock(id)
// ... proceed ... Defensive patterns
Strategy: validation
Validate before calling
// Only call Lock if the instance is not already locked
if s.lockID != "" {
return errors.New("refusing to Lock: this LocalState is already locked; call Unlock first")
}
return s.Lock(info) 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
- Pair every Lock with a deferred Unlock at the call site.
- Never retry Lock without an intervening Unlock.
- Give the LocalState a single owner goroutine; do not share across goroutines.
- Assert s.lockID == "" in tests before calling Lock.
When it happens
Trigger: Calling Lock twice on the same *LocalState without Unlock in between; a wrapper that retries Lock on transient errors without first unwinding the previous (partially) successful lock; calling Lock from multiple goroutines sharing one LocalState pointer; a state manager whose Unlock path returned early and left s.lockID set.
Common situations: A test helper that Locks, hits an error, then Locks again in cleanup; an embedder reusing the same LocalState across `plan` and `apply` without unlocking; a custom backend that wraps LocalState and double-locks on retry; concurrent CLI processes are NOT the cause here — this guard is per-instance.
Related errors
- LocalState not locked
- failed to lock azure state: %s
- state blob is already locked
- failed to lock state in Consul: %s
- invalid lock id: %q. current id: %q
AI-assisted analysis of hashicorp/terraform@c9def3e214 (2026-08-07).
Data as JSON: /api/errors/9fb1f6a2187d5be0.
Report an issue: GitHub.