etcd-io/etcd · error

unexpected lease in delete

Error message

unexpected lease in delete

What it means

After applying options, OpDelete verifies that no read/get-only option leaked into a delete operation. WithLease sets op.leaseID, which is only meaningful for Put (attach a lease to a key); a delete has no lease to attach, so ret.leaseID != 0 panics with "unexpected lease in delete". The check exists because applyOpts silently stores the option and the mismatch would otherwise be dropped or mis-sent to the server.

Source

Thrown at client/v3/op.go:277

	if IsOptsWithPrefix(opts) && IsOptsWithFromKey(opts) {
		panic("`WithPrefix` and `WithFromKey` cannot be set at the same time, choose one")
	}
	ret := Op{t: tRange, key: []byte(key)}
	ret.applyOpts(opts)
	return ret
}

// OpDelete returns "delete" operation based on given key and operation options.
func OpDelete(key string, opts ...OpOption) Op {
	// WithPrefix and WithFromKey are not supported together
	if IsOptsWithPrefix(opts) && IsOptsWithFromKey(opts) {
		panic("`WithPrefix` and `WithFromKey` cannot be set at the same time, choose one")
	}
	ret := Op{t: tDeleteRange, key: []byte(key)}
	ret.applyOpts(opts)
	switch {
	case ret.leaseID != 0:
		panic("unexpected lease in delete")
	case ret.limit != 0:
		panic("unexpected limit in delete")
	case ret.rev != 0:
		panic("unexpected revision in delete")
	case ret.sort != nil:
		panic("unexpected sort in delete")
	case ret.serializable:
		panic("unexpected serializable in delete")
	case ret.countOnly:
		panic("unexpected countOnly in delete")
	case ret.minModRev != 0, ret.maxModRev != 0:
		panic("unexpected mod revision filter in delete")
	case ret.minCreateRev != 0, ret.maxCreateRev != 0:
		panic("unexpected create revision filter in delete")
	case ret.filterDelete, ret.filterPut:
		panic("unexpected filter in delete")
	case ret.createdNotify:
		panic("unexpected createdNotify in delete")

View on GitHub (pinned to f744d457f4)

Solutions

  1. Remove WithLease from the Delete call — leases are attached on Put; deletion revokes nothing about the lease.
  2. If the intent is 'delete the key when the lease expires', that is automatic: just attach the lease at Put time and let it expire.
  3. If the intent is to revoke the lease, call cli.Revoke(ctx, id) instead of Delete with WithLease.
  4. Split option lists per operation kind in shared helpers (putOpts vs delOpts).

Example fix

// before
_, err := cli.Delete(ctx, "/session/1", clientv3.WithLease(sessLease.ID())) // panics

// after
_, err := cli.Delete(ctx, "/session/1")
// to free the lease itself: cli.Revoke(ctx, sessLease.ID())
Defensive patterns

Strategy: validation

Prevention

When it happens

Trigger: clientv3.OpDelete("/k", clientv3.WithLease(leaseID)) or cli.Delete(ctx, "/k", clientv3.WithLease(id)); sharing one []OpOption built for a Put with a Delete call; helper APIs that take 'op options' and forward them to every operation type.

Common situations: A generic write(key, val, opts...) helper where val=="" routes to Delete but options still include WithLease; retry wrappers that cache options across operation kinds; team conventions that bundle lease options with key lifecycle code that also deletes on expiry handling.

Related errors


AI-assisted analysis of etcd-io/etcd@f744d457f4 (2026-08-15). Data as JSON: /api/errors/eda42da2345e52ab. Report an issue: GitHub.