skip to content

Redis supports lexicographic range queries on Sorted Sets (ZRANGEBYLEX, or ZRANGE with the BYLEX option). What must be true of the data for them to be meaningful, what is the interval syntax, and what are they used for?

level: seniorimportance: nice to knowfreq 32%

answer

  1. all scores identical, or results are nonsense
  2. [ inclusive, ( exclusive, - min, + max
  3. prefix: [pre .. [pre\xff
  4. memcmp: case-sensitive, no locale
  5. normalise + delimiter + original payload

basics

~20 s

They only make sense when every member has the same score — then ordering is purely by member bytes and the sorted set becomes an ordered string index. Intervals need a prefix: [ inclusive, ( exclusive, - minimum, + maximum. Used for prefix search, autocomplete and cursor pagination.

solid answer

~50 s

When **all members share an identical score**, a Sorted Set orders purely by the member string's raw bytes, turning it into an ordered index over strings. `ZRANGE key <min> <max> BYLEX` (or the older `ZRANGEBYLEX`) queries that ordering; `ZLEXCOUNT` counts a range and `ZREMRANGEBYLEX` deletes one. **Syntax**: every endpoint must carry a marker — `[value` inclusive, `(value` exclusive, `-` the lowest possible string, `+` the highest. A bare value is a syntax error. Prefix search is `ZRANGE key "[pre" "[pre\xff" BYLEX`. **Uses**: autocomplete over normalised terms; a secondary index where the member encodes `attribute:id`; cursor-based pagination that resumes from the last member seen instead of an offset. **Caveats**: if scores differ, results are effectively meaningless — the command still runs, so the bug is silent. Comparison is byte-wise `memcmp`, so it is case-sensitive and not Unicode- or locale-aware: normalise (lowercase, strip accents) before inserting and append the original text after a separator so you can display it. Cost is O(log N + M).

code

text · 24 lines
text
> ZADD terms 0 kotlin 0 kafka 0 kubernetes 0 java 0 javascript
(integer) 5

> ZRANGE terms - + BYLEX           # whole index, byte order
1) "java"
2) "javascript"
3) "kafka"
4) "kotlin"
5) "kubernetes"

> ZRANGE terms "[k" "[k\xff" BYLEX LIMIT 0 10   # prefix 'k'
1) "kafka"
2) "kotlin"
3) "kubernetes"

> ZRANGE terms "(kafka" "+" BYLEX LIMIT 0 2     # cursor: after 'kafka'
1) "kotlin"
2) "kubernetes"

> ZLEXCOUNT terms "[j" "[j\xff"
(integer) 2

> ZRANGE terms "[k" "[k\xff"       # missing BYLEX -> parsed as scores
(error) ERR value is not a valid float

go deeper

for a junior

Know that lexicographic ranges only apply when all scores are equal and that endpoints use [ and ( markers with - and + for the extremes.

for a middle

Add the prefix-search idiom with the \xff upper bound, the LIMIT clause, and ZLEXCOUNT/ZREMRANGEBYLEX as companions.

for a senior

Emphasise the silent-failure risk when scores differ, the normalisation strategy for real text, cursor pagination over offsets, and the O(log N + M) cost model.

for a principal

Frame it as a cheap ordered prefix index with hard limits — no tokenisation, no fuzziness, no ranking, single-key so unsplittable — and name the threshold at which the requirement belongs in a search engine instead.

## The precondition A Sorted Set orders by score first and by member bytes only as a tiebreak. Lexicographic range queries exploit the tiebreak — so they are only meaningful when the primary ordering key is constant, i.e. **every member has the same score** (0 is the conventional choice). This is a genuine trap: Redis does not check the precondition. If some members have score 0 and others score 5, `BYLEX` still returns *something*, and that something is not a lexicographic range over the whole set. There is no error, no warning — just wrong results in production. Enforce the invariant in the write path: one place that inserts, always with the same score, ideally a script or a wrapper function. ## Interval syntax Endpoints are not bare strings; each needs a marker byte: - `[value` — inclusive - `(value` — exclusive - `-` — negative infinity for strings (before every possible member) - `+` — positive infinity (after every possible member) So `ZRANGE idx - + BYLEX` is the whole set in order, and `ZRANGE idx "[b" "(d" BYLEX` is everything from `b` inclusive up to but not including `d`. **Prefix search** is the workhorse: `ZRANGE idx "[kot" "[kot\xff" BYLEX LIMIT 0 10`. The upper bound works because `\xff` is the highest byte, so `kot\xff` sorts after every string starting with `kot` that uses ordinary characters. With arbitrary binary members you would need a more careful upper bound, but for text this idiom is standard. `LIMIT offset count` paginates, exactly as with score ranges. Large offsets are expensive (the engine must walk them), which is precisely why cursor pagination — remember the last member and use `(lastMember` as the next `min` — is the better pattern. Companion commands: `ZLEXCOUNT key min max` counts a lexicographic range, and `ZREMRANGEBYLEX key min max` deletes one, which is how you drop an entire prefix (say, all entries for a retired namespace) in one command. ## Byte comparison, not text collation Comparison is raw byte comparison. Consequences you must design for: - **Case sensitivity**: `Zebra` sorts before `apple` because uppercase letters have lower byte values. An autocomplete built on raw user input will look broken. - **No locale awareness**: accented characters, ligatures and non-Latin scripts sort by their UTF-8 byte sequences, not by any language's alphabet. `é` does not sit next to `e`. - **No normalisation**: trailing whitespace, different Unicode normalisation forms, and full-width characters all produce distinct, distantly-sorted members. The standard remedy is to store a **normalised sort key plus the original payload** in one member, separated by a delimiter that cannot appear in the key: `"kotlinKotlin Programming42"`. You query on the normalised prefix and split the member to render the original text and its id. Choose the delimiter carefully — it must sort low so it does not disturb prefix boundaries, and it must be impossible in the normalised key. ## What it is good for **Autocomplete.** One key per index (or per language/tenant), members are normalised terms, a prefix range with `LIMIT 0 10` gives the suggestions. Cheap and predictable: O(log N + M) with M bounded by your limit. **Secondary index.** Members encode `sortValue:entityId` so the set is an ordered index over some string attribute — usernames, email domains, SKU codes — supporting range scans that a plain Set or Hash cannot express. **Cursor pagination.** Instead of `LIMIT 5000 50`, remember the last member of the previous page and query `ZRANGE idx "(lastMember" + BYLEX LIMIT 0 50`. Constant cost per page regardless of depth, and stable under concurrent inserts in a way offsets are not. **Bulk prefix deletion.** `ZREMRANGEBYLEX idx "[tenant42:" "[tenant42:\xff"` removes an entire logical partition in one command — though note that removing very many members is O(log N + M) and will block for M's duration. ## Limits worth stating This is a prefix index, not a search engine. It does no tokenisation, no stemming, no fuzzy matching, no infix or suffix search, and no relevance ranking. "Find terms *containing* x" requires indexing every suffix, which multiplies memory by the average term length — occasionally worth it for small vocabularies, usually a signal that you want a real search index. And because a lexicographic index lives in one key, it is not splittable across cluster nodes; sharding means multiple keys and merging results client-side.

  • What happens if some members in the sorted set have different scores and you run a BYLEX query?
    Redis executes the command without complaint but the result is not a lexicographic range over the set, because the primary ordering is still by score and the member ordering only holds within equal-score groups. The failure is silent — you get a plausible-looking but incorrect subset, which is far worse than an error. The defence is to enforce a single constant score in the write path, ideally through one wrapper or script that is the only code allowed to insert into the index.
  • How do you make an autocomplete index behave sensibly for mixed-case and accented input?
    Normalise before inserting: lowercase, strip or fold accents, collapse whitespace, and apply a consistent Unicode normalisation form, then store that normalised key followed by a low-sorting delimiter and the original display text plus any id. Queries normalise the user's input the same way before building the prefix range. Byte comparison then behaves predictably, and you still have the original text to render because it travels inside the member.
  • Why is cursor pagination with BYLEX better than using LIMIT with a large offset?
    A large offset forces the engine to walk past all the skipped elements, so the cost of page N grows with N. Passing the last member seen as an exclusive lower bound, as in ZRANGE key (lastMember + BYLEX LIMIT 0 50, starts the scan directly at the right position, giving constant cost per page. It is also more stable under concurrent inserts and deletes, which shift offsets and cause items to be skipped or repeated across pages.

A phone book where every entry is filed with the same priority, so the only thing that orders it is the spelling — you can flip straight to 'Sm' through 'Sn', but only because someone normalised the spellings before printing.

saying these in an interview costs you the question

  • Running BYLEX queries on a sorted set whose members have differing scores and trusting the result
  • Omitting the [ or ( marker on range endpoints and expecting Redis to accept a bare string
  • Assuming lexicographic order matches human alphabetical order for mixed case or accented text
  • Believing BYLEX can find substrings or perform fuzzy matching rather than prefix ranges
  • Paginating deep result sets with large LIMIT offsets instead of resuming from the last member

context