XADD and XRANGE

A Redis stream is an append-only log data structure, and XADD and XRANGE are the two commands you reach for first when working with it. XADD appends a new entry to the end of a stream; XRANGE reads a slice of entries back out, ordered by the ID that Redis assigns each entry. Together they turn a Redis key into a lightweight, persistent, ordered event log — useful for activity feeds, IoT sensor readings, audit trails, or chat history — without standing up a dedicated message queue.

Overview / How it works

A stream lives at a single key, just like a string, list, or hash — but its type is stream, and calling a command for another type against it (or vice versa) returns a WRONGTYPE error. Internally, Redis stores a stream as a radix tree (a rax) whose nodes hold compact listpack-encoded groups of entries. This structure is specifically optimized for two things a log needs: fast appends at the tail, and fast range scans by ID — which is exactly what XADD and XRANGE do.

Every entry in a stream has a unique ID in the form <milliseconds-time>-<sequence>, for example 1691683200000-0. IDs are strictly increasing: each new entry’s ID must be greater than the previous one. When you pass * as the ID to XADD, Redis generates one automatically from the server’s current millisecond timestamp, incrementing the sequence number if multiple entries land in the same millisecond. You can also supply your own explicit ID (useful when re-inserting data from another source with known timestamps), but Redis will reject any ID that isn’t strictly greater than the stream’s current last ID — this is what keeps the log strictly ordered.

An entry itself is a small hash: one or more field-value pairs attached to that ID, e.g. event login, user alice. Unlike a Redis list, a stream is not consumed by reading — XRANGE is a non-destructive read, so the same entries can be read by many independent readers, any number of times, until they are explicitly trimmed or the key is deleted. (Reading via consumer groups, which track per-consumer progress through a stream, is covered in a later lesson — XADD/XRANGE are the foundation everything else builds on.)

Because Redis is single-threaded, an XADD call is atomic: the ID assignment and the entry write happen as one uninterruptible step, so two concurrent writers can never be assigned the same ID or interleave mid-write.

Syntax

XADD key [NOMKSTREAM] [MAXLEN | MINID [= | ~] threshold [LIMIT count]] <* | id> field value [field value ...]
  • key — the stream’s key name; if it doesn’t exist, XADD creates it automatically (unless NOMKSTREAM is given).
  • NOMKSTREAM — optional; if the key doesn’t already exist, do nothing instead of auto-creating it.
  • MAXLEN / MINID — optional trimming: cap the stream at roughly (with ~) or exactly (with =) N entries, or evict everything with an ID below a minimum.
  • * | id* to auto-generate the ID from the server clock, or an explicit <ms>-<seq> ID greater than the stream’s current last ID.
  • field value [field value ...] — one or more field-value pairs making up the entry; at least one pair is required.
XRANGE key start end [COUNT count]
  • key — the stream to read from.
  • start / end — an ID, or - (the smallest possible ID) and + (the largest possible ID) for open-ended ranges, both inclusive. Prefix an ID with ( to exclude it. A partial ID like 5 is treated as 5-0 for start and as the maximum sequence for end, so you can range-query by timestamp alone.
  • COUNT count — optional; limit the number of entries returned, useful for paging through a large stream.
Command Time complexity
XADD O(1) per call; O(M) additionally when trimming removes M entries
XRANGE O(N) where N is the number of entries returned (effectively O(1) with a small, constant COUNT)
XLEN O(1)

Examples

Example 1: Basic append and read

XADD stream:orders 1-1 item widget qty 3
XADD stream:orders 2-1 item gadget qty 1
XRANGE stream:orders - +
"1-1"
"2-1"
1) 1) "1-1"
   2) 1) "item"
      2) "widget"
      3) "qty"
      4) "3"
2) 1) "2-1"
   2) 1) "item"
      2) "gadget"
      3) "qty"
      4) "1"

Each XADD returns the ID it assigned to the new entry. XRANGE stream:orders - + then reads the entire stream from the lowest possible ID to the highest, returning an array where each element is a two-item array: the entry’s ID, followed by its flattened field-value list.

Example 2: Auto-generated IDs and range by timestamp

XADD stream:sensor:temp 1000-0 value 21.5
XADD stream:sensor:temp 1000-1 value 21.6
XADD stream:sensor:temp 2000-0 value 22.0
XADD stream:sensor:temp 3000-0 value 23.1
XRANGE stream:sensor:temp 1000 2000
"1000-0"
"1000-1"
"2000-0"
"3000-0"
1) 1) "1000-0"
   2) 1) "value"
      2) "21.5"
2) 1) "1000-1"
   2) 1) "value"
      2) "21.6"
3) 1) "2000-0"
   2) 1) "value"
      2) "22.0"

Here start and end are given as bare timestamps rather than full IDs. Redis expands 1000 to 1000-0 for the start bound and to the maximum sequence at 2000 for the end bound, so every entry timestamped between 1000 and 2000 inclusive comes back — the entry at 3000-0 falls outside the range and is correctly excluded.

Example 3: Auto IDs with COUNT and trimming

XADD stream:events * event login user alice
XADD stream:events * event view user alice
XADD stream:events * event logout user alice
XLEN stream:events
XRANGE stream:events - + COUNT 2
"1691683200000-0"
"1691683200001-0"
"1691683200002-0"
(integer) 3
1) 1) "1691683200000-0"
   2) 1) "event"
      2) "login"
      3) "user"
      4) "alice"
2) 1) "1691683200001-0"
   2) 1) "event"
      2) "view"
      3) "user"
      4) "alice"

The * IDs shown are illustrative — your actual run will show real millisecond timestamps from the moment each command executes, always increasing. XLEN confirms three entries were stored, and XRANGE ... COUNT 2 stops after the first two, which is how you’d page through a large stream instead of pulling it all into memory at once.

How it works step by step

When you run XADD stream:orders * item widget qty 3, Redis does the following, all within one atomic step on the single command-processing thread:

  • Looks up stream:orders; if it doesn’t exist, creates an empty stream (unless NOMKSTREAM was given).
  • Resolves the ID: if you passed *, it reads the server’s current millisecond time and compares it to the stream’s last ID, bumping the sequence if a collision would occur; if you passed an explicit ID, it verifies that ID is strictly greater than the last one and rejects the command otherwise.
  • Appends the field-value pairs as a new entry under that ID into the stream’s internal radix tree, packing it into the tail listpack node for compactness.
  • If MAXLEN or MINID was given, evicts entries from the head of the stream until the constraint is satisfied.
  • Returns the new entry’s ID as a bulk string reply.

When you run XRANGE, Redis walks the radix tree starting from the node that would contain start, scanning forward entry by entry until it passes end or hits COUNT, collecting each entry’s ID and fields into the reply array. Because the tree keeps entries sorted by ID, this is a sequential scan rather than a full-table search — the cost scales with entries returned, not with total stream size.

Common Mistakes

XADD stream:orders 1-1 item widget qty 3
XADD stream:orders 1-1 item gadget qty 1
"1-1"
(error) ERR The ID specified in XADD is equal or smaller than the target stream top item

The second XADD reuses ID 1-1, which is not strictly greater than the stream’s current last ID — Redis rejects it outright rather than silently overwriting or reordering. The fix is almost always to just pass * and let Redis assign a fresh, always-increasing ID, unless you have a specific reason to control IDs yourself (in which case, track the last ID you used).

SET config:app production
XRANGE config:app - +
OK
(error) WRONGTYPE Operation against a key holding the wrong kind of value

Every Redis key has exactly one type. config:app was created as a plain string with SET, so calling a stream command on it fails with WRONGTYPE. Use distinct, namespaced key names for different data types (stream:events vs. config:app) so this class of mistake is obvious at a glance.

A third, quieter mistake: adding to a stream with plain XADD ... * forever and never trimming it. A stream has no built-in expiration per entry — unlike a cache key, it will grow without bound and consume ever more memory unless you either set a TTL on the whole key with EXPIRE (appropriate for short-lived streams) or pass MAXLEN/MINID on every XADD to cap its size (appropriate for a rolling log).

Best Practices

  • Let Redis assign IDs with * unless you have an external, already-ordered timestamp source to replay — hand-rolled IDs are a common source of the “equal or smaller” error.
  • Cap long-lived streams with MAXLEN ~ N (approximate trim) on every XADD rather than an exact trim — the ~ form lets Redis trim in whole listpack-node chunks, which is far cheaper than trimming to an exact count.
  • Use COUNT with XRANGE when paging through a large stream instead of reading the whole range with - + at once.
  • Namespace stream keys clearly, e.g. stream:orders, stream:sensor:temp, so a type mismatch is easy to spot before it becomes a WRONGTYPE error.
  • Use XLEN to check size before an unbounded XRANGE - + in production code, the same way you’d think twice before KEYS * on a large keyspace.
  • Reach for a sorted set instead of a stream if you need to re-rank or update existing entries — streams are append-only and don’t support modifying an existing entry’s fields in place.

Practice Exercises

  • Create a stream stream:signups and add three entries with auto-generated IDs, each with fields email and plan. Then use XRANGE to read back only the first two entries using COUNT.
  • Add entries to stream:metrics using explicit IDs 100-0, 200-0, and 300-0. Write a single XRANGE call that returns only the entry at 200-0 by using an exclusive lower bound and an inclusive upper bound.
  • Add five entries to stream:log using XADD ... MAXLEN 3 * on every call. Confirm with XLEN that the stream never grows past 3 entries, and use XRANGE to see which entries survived the trimming.

Summary

  • XADD appends a field-value entry to a stream and returns its ID; XRANGE reads entries back by ID range, oldest to newest.
  • Entry IDs have the form <ms>-<seq> and are always strictly increasing; use * to let Redis generate them.
  • XRANGE key - + reads everything; partial IDs, exclusive bounds with (, and COUNT let you narrow or page the read.
  • Streams are append-only and non-destructively read — unlike lists, reading with XRANGE doesn’t remove entries.
  • Streams grow unbounded unless trimmed with MAXLEN/MINID or the key is given a TTL — plan for this before it becomes a memory problem.
  • A type mismatch (e.g. running XRANGE on a string key) always returns WRONGTYPE, and reusing or under-shooting an ID always returns an error — both are caught immediately, not silently.