hashicorp/terraform · error

Workspace is already locked

Error message

Workspace is already locked: %s

What it means

Thrown by RemoteClient.Lock in the pg backend when the workspace row exists but pg_try_advisory_lock(id) on its primary-key id returns false - meaning the advisory lock for that specific workspace is already held by another session. Before returning, the code releases the attempted -1 creation lock to avoid deadlocking creation. Returned as *statemgr.LockError.

Solutions

  1. Confirm no other apply is running on this workspace (`SELECT * FROM pg_locks WHERE locktype='advisory';`).
  2. Run `tofu force-unlock <lock-id>` if the holder is a dead session.
  3. Re-run apply after the other run completes.
  4. Add workspace-level locking in CI to prevent overlap.
Defensive patterns

Strategy: validation

Validate before calling

// preflight: check whether this workspace's advisory lock is held
var held bool
db.QueryRow(`SELECT EXISTS(SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=$1)`, wsID).Scan(&held)
if held { return fmt.Errorf("workspace %s is currently locked; retry or force-unlock", name) }

Type guard

func isWorkspaceLockedErr(err error) bool {
    return err != nil && strings.Contains(err.Error(), "Workspace is already locked")
}

Try / catch

if _, err := c.Lock(info); err != nil {
    var le *statemgr.LockError
    if errors.As(err, &le) && le.Info != nil {
        return fmt.Errorf("busy, held by %s since %s; run `tofu force-unlock %s`", le.Info.Who, le.Info.Created, le.Info.ID)
    }
    return err
}

Prevention

When it happens

Trigger: Lock() SELECTs the row, scans didLock as 'false' (the per-workspace lock is held), calls lockUnlock('-1') to release the creation lock, then returns. Triggered when a second `tofu apply` runs on an already-locked workspace.

Common situations: Concurrent applies to the same workspace; a previous apply crashed without releasing its lock; an interactive `tofu apply` is running in another terminal; CI overlap on the same workspace.

Related errors


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

Appendix: source

Thrown at internal/backend/remote-state/pg/client.go:120

	switch {
	case err == sql.ErrNoRows:
		// No rows means we're creating the workspace. Take the creation lock.
		innerRow := c.Client.QueryRow(`SELECT pg_try_advisory_lock(-1)`)
		var innerDidLock []byte
		err := innerRow.Scan(&innerDidLock)
		if err != nil {
			return "", &statemgr.LockError{Info: info, Err: err}
		}
		if string(innerDidLock) == "false" {
			return "", &statemgr.LockError{Info: info, Err: fmt.Errorf("Already locked for workspace creation: %s", c.Name)}
		}
		info.Path = "-1"
	case err != nil:
		return "", &statemgr.LockError{Info: info, Err: err}
	case string(didLock) == "false":
		// Existing workspace is already locked. Release the attempted creation lock.
		lockUnlock("-1")
		return "", &statemgr.LockError{Info: info, Err: fmt.Errorf("Workspace is already locked: %s", c.Name)}
	case string(didLockForCreate) == "false":
		// Someone has the creation lock already. Release the existing workspace because it might not be safe to touch.
		lockUnlock(string(pgLockId))
		return "", &statemgr.LockError{Info: info, Err: fmt.Errorf("Cannot lock workspace; already locked for workspace creation: %s", c.Name)}
	default:
		// Existing workspace is now locked. Release the attempted creation lock.
		lockUnlock("-1")
		info.Path = string(pgLockId)
	}
	c.info = info

	return info.ID, nil
}

func (c *RemoteClient) getLockInfo() (*statemgr.LockInfo, error) {
	return c.info, nil
}

View on GitHub (pinned to d32a084675)