Lists Explained
A Redis List is an ordered collection of strings that behaves like a linked list: you can push and pop elements from either end in constant time, and elements keep whatever order you inserted them in. Lists are the natural structure for anything that needs order and endpoint access — message queues, task pipelines, recent-activity feeds, undo stacks, and simple pub/sub replacements. Unlike a Set, a List allows duplicate values, and unlike a Sorted Set, its ordering is purely insertion order, not a score.
Overview: How Redis Lists Work
Internally, a Redis List is stored as a quicklist: a doubly linked list of nodes, where each node is itself a compact, memory-efficient listpack (a flat, contiguous byte array holding several elements). For small lists, this means the whole list often lives in a single listpack node, giving excellent cache locality and low memory overhead. As a list grows past configured size thresholds, Redis splits it across more nodes, still linked together, so it can keep scaling without needing one giant contiguous block of memory. This design is why pushing or popping at either end of a list is O(1): Redis just adds or removes an entry at the head or tail node of the linked structure, with no need to shift every other element (contrast this with an array-backed list in many programming languages, where inserting at the front is O(N)).
Because Redis executes commands one at a time on a single main thread, every individual List command — LPUSH, RPOP, LREM, and so on — runs to completion without any other client’s command interleaving. That single-threaded guarantee is what makes Lists safe to use as queues from multiple producers and consumers without extra application-level locking: two clients calling LPUSH at the “same time” are actually serialized by Redis, so you never lose or corrupt an element.
Elements in a List are always strings (Redis has no concept of a “typed” list — if you store a number, it is stored and returned as its string form). A List can theoretically hold up to about 4 billion elements, though in practice you are bounded by available memory and by the fact that operations touching the middle of a very long list (like LINDEX far from either end) get slower the deeper into the list they reach. For consuming a list as a work queue, Redis also offers blocking variants, BLPOP and BRPOP, which make a client wait (up to a timeout) for an element to appear instead of polling, and LMOVE, which atomically moves an element from one list straight onto another — useful for reliable queue patterns where you move a job into a “processing” list while you work on it.
Syntax
The general shape of the core List commands looks like this:
LPUSH key element [element ...]
RPUSH key element [element ...]
LRANGE key start stop
LPOP key [count]
RPOP key [count]
LLEN key
LINDEX key index
LSET key index value
LINSERT key BEFORE|AFTER pivot element
LREM key count value
LTRIM key start stop
| Argument | Meaning |
|---|---|
key |
The name of the list key, e.g. queue:emails |
element / value |
The string to store; multiple elements can be pushed in one call |
start / stop |
Zero-based indexes; 0 is the head, -1 is the tail, negative indexes count from the end |
count (LPOP/RPOP) |
Optional number of elements to pop at once (Redis 6.2+); omit for a single element |
count (LREM) |
How many matching occurrences to remove: positive scans head-to-tail, negative scans tail-to-head, 0 removes all |
index |
Zero-based position to read (LINDEX) or overwrite (LSET) |
pivot |
An existing element value that LINSERT anchors the new element before or after |
Command reference and time complexity
| Command | Time Complexity | What it does |
|---|---|---|
LPUSH / RPUSH |
O(1) per element | Insert one or more elements at the head/tail |
LPOP / RPOP |
O(1) for a single element, O(N) for count elements |
Remove and return element(s) from the head/tail |
LLEN |
O(1) | Return the number of elements |
LRANGE |
O(S+N), S = offset from head, N = elements returned | Return a range of elements |
LINDEX |
O(N) to traverse, O(1) near head/tail | Get the element at an index |
LSET |
O(N) to traverse, O(1) near head/tail | Overwrite the element at an index |
LINSERT |
O(N) | Insert before or after a pivot element |
LREM |
O(N+M), M = elements removed | Remove matching elements |
LTRIM |
O(N), N = elements removed | Shrink the list down to the given range |
BLPOP / BRPOP |
O(1) | Blocking pop; waits for an element up to a timeout |
Examples
Example 1: Basic push, range, and pop
RPUSH fruits:cart "apple"
RPUSH fruits:cart "banana"
RPUSH fruits:cart "cherry"
LRANGE fruits:cart 0 -1
LLEN fruits:cart
LPOP fruits:cart
LRANGE fruits:cart 0 -1
Output:
(integer) 1
(integer) 2
(integer) 3
1) "apple"
2) "banana"
3) "cherry"
(integer) 3
"apple"
1) "banana"
2) "cherry"
Each RPUSH appends to the tail and returns the new list length. LRANGE fruits:cart 0 -1 reads the whole list from head (index 0) to tail (index -1). LPOP removes and returns the head element (apple), and the final LRANGE confirms it is gone.
Example 2: A FIFO queue with LPUSH and RPOP
LPUSH queue:emails "welcome:user42"
LPUSH queue:emails "reset:user17"
LPUSH queue:emails "invoice:user88"
LRANGE queue:emails 0 -1
RPOP queue:emails
RPOP queue:emails
LLEN queue:emails
Output:
(integer) 1
(integer) 2
(integer) 3
1) "invoice:user88"
2) "reset:user17"
3) "welcome:user42"
"welcome:user42"
"reset:user17"
(integer) 1
This is a classic queue pattern: producers LPUSH new jobs onto the head, and a consumer RPOPs from the tail, so the first job pushed (welcome:user42) is the first one popped — first in, first out. Notice that LPUSH pushes onto the head each time, so the most recently pushed item ends up nearest the head and the oldest item drifts toward the tail, ready to be popped.
Example 3: Managing a task list with LINSERT, LSET, LREM, and LTRIM
RPUSH tasks:project42 "design"
RPUSH tasks:project42 "build"
RPUSH tasks:project42 "test"
RPUSH tasks:project42 "deploy"
LINSERT tasks:project42 BEFORE "test" "review"
LSET tasks:project42 0 "planning"
LRANGE tasks:project42 0 -1
LREM tasks:project42 1 "review"
LRANGE tasks:project42 0 -1
LTRIM tasks:project42 0 2
LRANGE tasks:project42 0 -1
Output:
(integer) 1
(integer) 2
(integer) 3
(integer) 4
(integer) 5
OK
1) "planning"
2) "build"
3) "review"
4) "test"
5) "deploy"
(integer) 1
1) "planning"
2) "build"
3) "test"
4) "deploy"
OK
1) "planning"
2) "build"
3) "test"
LINSERT drops a new review step right before test. LSET overwrites index 0 in place, renaming design to planning without changing the list’s length. LREM tasks:project42 1 "review" scans from the head and removes the first matching occurrence of review. Finally, LTRIM tasks:project42 0 2 destructively shrinks the list down to just indexes 0 through 2, discarding deploy entirely — a common way to cap a list (like a capped activity log) at a fixed size.
How It Works Step by Step
When you run RPUSH mylist "x" on a key that doesn’t exist yet, Redis creates a new quicklist object for that key, allocates a single listpack node, and stores "x" in it. Subsequent pushes append into that same node’s contiguous buffer as long as it stays under the configured node size limits; once a node would grow too large (by element count or byte size), Redis starts a new listpack node and links it onto the quicklist, so the list is really a chain of small, tightly packed arrays rather than one array or one node per element. A push at the head or tail only ever touches the node at that end, which is why it stays O(1) regardless of how long the overall list is.
A read like LRANGE key 0 -1 walks the linked chain of nodes from the head, copying out each element until it reaches the requested stop index (or the tail, for -1), which is why its cost scales with how many elements you actually ask for (O(S+N)), not with the size of the whole list. LPOP/RPOP remove the element(s) closest to the requested end and, if a listpack node becomes empty, that node is unlinked and freed. Because every one of these steps happens inside a single command execution on Redis’s single main thread, there is no possibility of another client’s command running “in the middle” of your push or pop — the operation either has not started or has fully finished from every other client’s point of view.
Common Mistakes
Mistake 1: Using a String key as if it were a List
Every Redis key has exactly one type, fixed by whichever command first created it. Calling a List command on a key that already holds a String (or vice versa) fails with a WRONGTYPE error rather than silently doing something unexpected:
SET counter:visits "100"
LPUSH counter:visits "200"
Output:
OK
(error) WRONGTYPE Operation against a key holding the wrong kind of value
The fix is to use the command that matches the type you actually stored. If counter:visits is meant to be a running count, use the String/counter commands on it instead of a list command:
SET counter:visits "100"
INCR counter:visits
Output:
OK
(integer) 101
Mistake 2: Always pulling the entire list with LRANGE key 0 -1
On a small list this is harmless, but on a list with millions of elements, LRANGE key 0 -1 forces Redis to walk and serialize every single element in one command, on the single main thread, while every other client’s command waits its turn — very similar to the danger of KEYS * on a large keyspace. The fix is to paginate: request bounded ranges instead of the whole list.
RPUSH events:stream "e1"
RPUSH events:stream "e2"
RPUSH events:stream "e3"
RPUSH events:stream "e4"
RPUSH events:stream "e5"
LRANGE events:stream 0 1
LRANGE events:stream 2 3
Output:
(integer) 1
(integer) 2
(integer) 3
(integer) 4
(integer) 5
1) "e1"
2) "e2"
1) "e3"
2) "e4"
Fetching two elements at a time (or however many your UI or job needs per batch) keeps each command’s cost bounded and predictable, no matter how large the underlying list grows.
Best Practices
- Use
LPUSH/RPOP(orRPUSH/LPOP) as a simple FIFO queue, and preferBLPOP/BRPOPover polling with a sleep loop when a consumer should wait for new work. - Cap unbounded lists (activity feeds, logs) with periodic
LTRIMcalls so memory usage doesn’t grow forever. - Paginate reads with bounded
LRANGE start stopcalls instead ofLRANGE key 0 -1once a list can grow large. - Remember Lists allow duplicate values and have no built-in uniqueness — use a Set or Sorted Set if you need to guarantee distinct members.
- For “move a job from the queue to a processing list” patterns, use
LMOVEso the move is atomic and you never lose a job between popping it and pushing it elsewhere. - Give list keys clear, namespaced names (
queue:emails,tasks:project42) so their purpose and type are obvious from the key alone. - Avoid pushing very large individual elements (megabyte-sized strings) into a List; if your payloads are big, store them separately and push a reference (an ID) instead.
Practice Exercises
- Build a simple undo stack: push three actions onto a list named
undo:doc42usingRPUSH, then “undo” the two most recent actions using the appropriate pop command. What order do they come back in? - Create a capped recent-activity feed: push five events onto
feed:user7, then useLTRIMso only the three most recent events remain. Verify the final contents withLRANGE. - Simulate a WRONGTYPE bug on purpose:
SETa key as a string, then try anRPUSHon the same key and observe the error. Then fix it by using a different key name for the list.
Summary
- A Redis List is an ordered, duplicate-allowing collection of strings backed internally by a quicklist (a linked chain of compact listpack nodes).
- Pushing and popping at either end (
LPUSH,RPUSH,LPOP,RPOP) is O(1); operations that touch a range or the middle of the list (LRANGE,LINDEX,LINSERT) cost more the further they reach. - Redis’s single-threaded execution model makes every individual List command atomic, which is what makes Lists safe as multi-producer/multi-consumer queues without extra locking.
- Calling a List command on a key holding a different type (or vice versa) raises a
WRONGTYPEerror instead of doing something silently wrong. - Prefer bounded, paginated
LRANGEcalls overLRANGE key 0 -1on lists that can grow large, and useLTRIMto cap unbounded lists. - Use
BLPOP/BRPOPfor efficient blocking consumption andLMOVEfor atomic queue-to-queue job handoff.
