skip to content

Explain the ZADD flags NX, XX, GT, LT, CH and INCR in Redis, and give a situation where each one changes the outcome.

level: middleimportance: should knowfreq 40%

answer

  1. NX = insert only; XX = update only
  2. GT/LT = only raise / only lower (still insert new)
  3. GT|LT|NX mutually exclusive; GT/LT + XX OK
  4. CH = reply counts changes, not just adds
  5. INCR = ZINCRBY semantics, nil if condition blocked

basics

~20 s

NX only inserts new members, XX only updates existing ones, GT and LT update only if the new score is greater or lesser. CH changes the reply to count added-or-changed members. INCR makes ZADD behave like ZINCRBY and return the new score, or nil if a condition blocked it.

solid answer

~60 s

`ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member ...` - **NX** — add only if the member does not exist; never touches an existing score. Use for "first seen at" timestamps you must not overwrite. - **XX** — update only if the member already exists; never creates. Use to refresh a heartbeat without resurrecting an entry that was deliberately removed. - **GT** — update only if the new score is **greater** than the stored one (still inserts new members). This is the high-water-mark flag: personal bests, latest-timestamp wins, retry-safe writes. - **LT** — the mirror image: only lower. Useful for shortest-time or best-price semantics. - **CH** — changes the integer reply from "members added" to "members added **or** changed", so you can tell whether an update actually did anything. - **INCR** — behaves like `ZINCRBY`: the score argument is a delta and the reply is the new score. Only one member may be given, and the reply is **nil** if `NX`/`XX`/`GT`/`LT` prevented the operation. `GT`, `LT` and `NX` are mutually exclusive; `GT`/`LT` may be combined with `XX`. The value of these flags is that they collapse a read-compare-write sequence into one atomic command, eliminating a race.

code

text · 22 lines
text
> ZADD board 100 alice
(integer) 1

> ZADD board NX 500 alice      # exists -> untouched
(integer) 0
> ZSCORE board alice
"100"

> ZADD board XX 200 bob        # absent -> not created
(integer) 0
> ZSCORE board bob
(nil)

> ZADD board GT CH 90 alice    # 90 < 100 -> no change
(integer) 0
> ZADD board GT CH 150 alice   # 150 > 100 -> updated
(integer) 1

> ZADD board NX INCR 5 alice   # blocked by NX
(nil)
> ZADD board XX INCR 5 alice   # exists -> incremented
"155"

go deeper

for a junior

Know that NX only inserts and XX only updates, and that the plain reply counts newly added members.

for a middle

Cover all six flags with a use case each, the mutual-exclusion rules, and the three different reply shapes including nil from INCR.

for a senior

Frame them as race elimination: each flag folds a check-then-act sequence into one atomic command, which matters most for retry-safe and out-of-order-safe writes such as timestamp or personal-best updates.

for a principal

Generalise to the design rule — push conditions into the datastore where they are evaluated atomically, and escalate to a server-side script when the condition outgrows the built-in flags rather than reintroducing client-side compare-and-set.

## Why the flags exist Without them, half of the useful updates to a Sorted Set are a **check-then-act** sequence: read the current score, decide, write. Between the read and the write another client can change the value, and the loser silently overwrites the winner. The `ZADD` flags move the decision inside the single atomic command, so the condition is evaluated against the value the write will actually replace. ## Existence conditions **`NX`** — *only add, never update.* If the member exists, `ZADD` leaves it completely alone. Canonical use: a "first observed" timestamp. `ZADD firstseen NX <now> user:42` records when a user was first seen and is safe to call on every request forever — the value never drifts. Trying to achieve this with a read plus a conditional write is racy on the first two concurrent requests. **`XX`** — *only update, never add.* If the member is absent, nothing happens. Canonical use: refreshing a liveness or lease timestamp for an entry that some other process may have deliberately evicted. `ZADD active XX <now> node:7` refreshes node 7's heartbeat but will not resurrect a node that was removed from the registry — without `XX` a late heartbeat from a decommissioned node silently re-registers it. ## Comparison conditions **`GT`** — update only if the new score is strictly **greater** than the current score. A member that does not exist is still added (the condition only guards *updates*). This is the flag people most often did not know they needed. Three patterns: 1. **Personal bests / high scores** — a retried request or an out-of-order older result cannot lower a stored best. 2. **Latest-timestamp-wins** — when the score is an event timestamp, `GT` makes writes idempotent and reordering-safe, which is exactly what you want consuming an at-least-once stream of events that may arrive out of order. 3. **Extending a lease only forward** — `ZADD leases XX GT <newExpiry> <holder>` refreshes a lease without ever shortening it. **`LT`** — the mirror: update only if strictly lower. Use for minimum-seeking scores such as best lap time, lowest observed price, or earliest deadline. Combination rules: `GT`, `LT` and `NX` are mutually exclusive — an error if you combine them, which makes sense because `NX` already forbids all updates. `GT`/`LT` **can** be combined with `XX`, giving "update only if it exists and only in this direction". ## Reply-shaping flags **`CH`** — by default `ZADD` returns the number of members **added**. Update an existing member's score and you get 0, which reads like failure. `CH` ("changed") makes the reply count members added *or* whose score changed, so a return of 1 means "something actually happened". Note that setting a member to the score it already has counts as unchanged even with `CH`. **`INCR`** — turns the score argument into a delta and the reply into the resulting score, i.e. `ZINCRBY` with conditions attached. Constraints: exactly one score/member pair, and the reply is **nil** when a condition suppressed the operation. That nil is a feature: `ZADD key NX INCR 1 member` returns the new score on the first call and nil on every subsequent one — an atomic "was I first?" that also gives you the value. `ZADD key XX INCR <delta> member` increments only members that already exist, which is how you avoid accidentally creating entries for entities that have been deleted. ## Return-value summary | Form | Reply | |---|---| | plain | count of members **added** | | with `CH` | count added **or changed** | | with `INCR` | the new score, or **nil** if a condition blocked it | A nil from an `INCR` form is not an error and must be handled distinctly from a zero score — a client that maps nil to 0 will report a member as having score zero when in fact it was not touched. ## Practical guidance When you find yourself writing "read the score, if … then write", stop and check whether a flag expresses the condition. In practice `GT` and `NX` cover the large majority of leaderboard, deduplication and event-timestamp cases, and using them removes both a round trip and a class of concurrency bug. When the condition is genuinely richer than these flags — say, "update only if the new score exceeds the old by at least 10" — that is what a small Lua script is for, evaluated atomically on the server.

  • Why can GT not be combined with NX?
    NX means 'never modify an existing member', while GT is a rule about *how* an existing member may be modified — the two conditions cannot both govern the same update, so Redis rejects the combination as an error rather than silently picking one. GT already inserts members that do not exist, which is the part of NX's behaviour people usually want alongside it. If you need 'insert if absent, otherwise only raise', GT alone is exactly that.
  • You call ZADD with INCR and get a nil reply. What happened and how should the client treat it?
    A conditional flag suppressed the operation: NX blocked it because the member already existed, XX because it did not, or GT/LT because the resulting score would have moved the wrong way. Nothing was written and no error occurred. The client must distinguish nil from a numeric zero — mapping nil to 0 would report a score that does not exist, so treat it as 'no change, current value unknown' and read it separately if needed.
  • How would you express a condition the flags cannot cover, such as 'update only if the new score is at least 10 higher'?
    Use a small Lua script, which Redis evaluates atomically on the server, so the read of the old score and the conditional write cannot be interleaved with another client's write. The script reads ZSCORE, applies whatever arithmetic or business rule you need, and calls ZADD only when the rule passes, returning whatever the caller needs to know. This keeps the single-round-trip, race-free property of the flags while allowing arbitrary conditions.

saying these in an interview costs you the question

  • Believing GT refuses to insert a member that does not exist yet
  • Reading ZADD's default 0 as failure when it merely means no new member was added
  • Combining GT with NX and expecting Redis to apply both
  • Treating a nil reply from the INCR form as a score of zero
  • Emulating GT with a client-side read-compare-write and assuming it is safe under concurrency

context