hashicorp/terraform · error · statemgr.LockError

Workspace is already locked: %s

Error message

Workspace is already locked: %s

What it means

Returned by RemoteClient.Lock (pg/client.go:120) for an existing workspace. The row was found but pg_try_advisory_lock(<workspace id>) returned false, meaning another session already holds the per-workspace advisory lock. This is the standard, expected lock-contention error for the Postgres backend.

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 c9def3e214)

Solutions

  1. Coordinate with the other run and wait for it to finish, then retry.
  2. If the holder is dead, use `terraform force-unlock <lock-id>` — though for Postgres advisory locks this typically means terminating the holding DB backend.
  3. Inspect pg_locks (objid = workspace id) and pg_stat_activity to identify the holder.
Defensive patterns

Strategy: try-catch

Validate before calling

// Detect an existing holder before locking
// SELECT pid FROM pg_locks l JOIN pg_stat_activity a USING (pid)
//  WHERE l.locktype='advisory' AND l.objid=<workspaceId>;

Type guard

// Per-workspace lock contention returns a typed error
// var lockErr *statemgr.LockError
// if errors.As(err, &lockErr) {
//   who := lockErr.Info // operation, who, created
// }

Try / catch

// Standard lock-contention handling: surface who holds it, offer force-unlock
// _, err := client.Lock(info)
// var le *statemgr.LockError
// if errors.As(err, &le) {
//   fmt.Printf("locked by %s since %s; use terraform force-unlock if stale\n", le.Info.Who, le.Info.Created)
// }

Prevention

When it happens

Trigger: Concurrent `terraform apply`/plan/refresh on the same workspace; a previous run crashed but its Postgres connection still holds the advisory lock.

Common situations: Two developers or two CI runs targeting the same workspace; a long apply whose connection lingered; a killed process whose pooler kept the session alive.

Related errors


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