hashicorp/terraform · error

Error unlocking S3 state. Lock ID

Error message

Error unlocking S3 state. Lock ID: %s

Error: %s

You may have to force-unlock this state in order to use it again.

What it means

Wrapped by the lockUnlock closure inside Backend.StateMgr (S3) when stateMgr.Unlock(lockId) fails during cleanup of an init that errored after acquiring the lock. The errStateUnlock template tells the operator they may need to force-unlock. The original error is chained as parent; the unlock failure is reported alongside it.

Solutions

  1. Run `tofu force-unlock <lock-id>` to clear the DynamoDB lock row if still present.
  2. Confirm the DynamoDB lock table exists and is reachable.
  3. Re-run `tofu init` / `tofu apply` after clearing the lock.
  4. Audit IAM permissions for dynamodb:DeleteItem on the lock table.
Defensive patterns

Strategy: try-catch

Validate before calling

// preflight: ensure the lock row still exists before attempting unlock
_, err := dbClient.GetItem(ctx, &dynamodb.GetItemInput{TableName: aws.String(lockTable), Key: map[string]types.AttributeValue{"LockID": &types.AttributeValueMemberS{Value: lockID}}})
if err != nil { return fmt.Errorf("lock table unreadable; unlock may fail: %w", err) }

Try / catch

if err := stateMgr.Unlock(lockId); err != nil {
    log.Printf("WARN unlock failed for %s; operator may need `tofu force-unlock %s`: %v", lockId, lockId, err)
    return parent
}

Prevention

When it happens

Trigger: stateMgr.Unlock(lockId) returns err != nil in lockUnlock, called from the RefreshState/WriteState/PersistState error branches in the init path. Causes: DynamoDB unreachable, lock row deleted out-of-band, IAM permission revoked mid-run, or the lock ID no longer matches the row (someone force-unlocked already).

Common situations: DynamoDB table deleted during apply; throttling on the lock table; another operator force-unlocked the same state mid-run; AWS region impairment.

Related errors


AI-assisted analysis of hashicorp/terraform@d32a084675 (2026-08-11). Data as JSON: /api/errors/c70d6e36937db009. Report an issue: GitHub.

Appendix: source

Thrown at internal/backend/remote-state/s3/backend_state.go:223

			exists = true
			break
		}
	}

	// We need to create the object so it's listed by States.
	if !exists {
		// take a lock on this state while we write it
		lockInfo := statemgr.NewLockInfo()
		lockInfo.Operation = "init"
		lockId, err := client.Lock(lockInfo)
		if err != nil {
			return nil, diags.Append(fmt.Errorf("failed to lock s3 state: %s", err))
		}

		// Local helper function so we can call it multiple places
		lockUnlock := func(parent error) error {
			if err := stateMgr.Unlock(lockId); err != nil {
				return fmt.Errorf(strings.TrimSpace(errStateUnlock), lockId, err)
			}
			return parent
		}

		// Grab the value
		// This is to ensure that no one beat us to writing a state between
		// the `exists` check and taking the lock.
		if err := stateMgr.RefreshState(); err != nil {
			err = lockUnlock(err)
			return nil, diags.Append(err)
		}

		// If we have no state, we have to create an empty state
		if v := stateMgr.State(); v == nil {
			if err := stateMgr.WriteState(states.NewState()); err != nil {
				err = lockUnlock(err)
				return nil, diags.Append(err)
			}

View on GitHub (pinned to d32a084675)