LPUSH, RPUSH, and LRANGE
Redis lists are ordered collections of strings, and the three commands you will reach for constantly are LPUSH (push onto the left/head), RPUSH (push onto the right/tail), and LRANGE (read back a range of elements by index). Together they turn a Redis list into a queue, a stack, or a capped activity feed. Because pushes to either end are O(1), lists stay fast whether you’re building a job queue that grows for hours or a small “last 10 events” widget.
Overview: How Lists Work in Redis
A Redis list is a collection of string values kept in strict insertion order, addressable by numeric index, and allowed to contain duplicate values. Unlike a Redis SET or sorted set, a list has no notion of uniqueness or score — the only thing that matters is position. Internally, small lists are stored using a compact, contiguous encoding called listpack, and Redis automatically converts to a quicklist — a doubly linked list of listpack nodes — once the list grows past configured size thresholds. This layout is why pushing onto either end of a list never requires shifting every other element in memory: the head and tail are always directly reachable, so LPUSH and RPUSH are both O(1) per element no matter how many items are already stored.
Because Redis executes one command at a time on a single main thread, every LPUSH, RPUSH, or LRANGE call runs to completion before the next command starts. There is no possibility of two clients interleaving pushes in a way that corrupts ordering — the whole operation is atomic by construction. This makes lists a natural fit for job queues (a producer uses RPUSH to enqueue while a worker uses LPOP or the blocking BLPOP, covered in a later lesson, to dequeue), capped activity feeds (LPUSH the newest event onto the head, then trim the tail), and any “most recent N items” pattern where you want fast writes without a database round trip.
A list key that doesn’t exist yet is treated as an empty list: pushing to a nonexistent key creates it on the spot, and once the last element is removed the key is deleted automatically. You never end up with an empty, orphaned list object sitting in memory.
Syntax
All three commands operate on a single list key. LPUSH and RPUSH accept one or more values to add; LRANGE accepts a start and stop index to read back.
LPUSH key value [value ...]
RPUSH key value [value ...]
LRANGE key start stop
| Argument | Meaning |
|---|---|
key |
The name of the list. Created automatically on the first push if it doesn’t already exist. |
value |
One or more string values to push. With multiple values in one call, each is inserted in turn — see the ordering note in Common Mistakes below. |
start / stop |
Zero-based indexes for LRANGE. 0 is the head (leftmost) element. Negative indexes count from the tail: -1 is the last element, -2 the second-to-last, and so on. |
Both start and stop are inclusive, and out-of-range indexes are clamped rather than causing an error — asking for more elements than exist simply returns everything available.
| Command | Time Complexity |
|---|---|
LPUSH |
O(1) per element pushed (O(N) when pushing N elements in a single call) |
RPUSH |
O(1) per element pushed (O(N) when pushing N elements in a single call) |
LRANGE |
O(S+N), where S is the offset of start from the head and N is the number of elements returned |
Examples
Example 1: Building a simple queue
Start with RPUSH to append three tasks in order, inspect the whole list with LRANGE, then use LPUSH to jump a priority task to the front.
RPUSH queue:tasks "task1" "task2" "task3"
LRANGE queue:tasks 0 -1
LPUSH queue:tasks "task0"
LRANGE queue:tasks 0 -1
(integer) 3
1) "task1"
2) "task2"
3) "task3"
(integer) 4
1) "task0"
2) "task1"
3) "task2"
4) "task3"
The first RPUSH returns (integer) 3, the new length of the list, and LRANGE queue:tasks 0 -1 confirms the tasks landed in the order they were appended. The following LPUSH inserts task0 at the head, growing the list to 4 elements, and the final LRANGE shows it now sits first — exactly the behavior you want for a queue where urgent items should jump ahead of items appended with RPUSH.
Example 2: A capped activity feed with LPUSH
Notification feeds usually want the newest item first. Pushing every new event with LPUSH keeps the most recent entry at index 0 without any extra sorting.
LPUSH feed:user:42 "Ada commented on your post"
LPUSH feed:user:42 "Ben liked your photo"
LPUSH feed:user:42 "Cy followed you"
LRANGE feed:user:42 0 1
LLEN feed:user:42
(integer) 1
(integer) 2
(integer) 3
1) "Cy followed you"
2) "Ben liked your photo"
(integer) 3
Each LPUSH returns the list’s new length after the insert. Since every new event is pushed onto the head, LRANGE feed:user:42 0 1 — “give me the two newest” — returns Cy’s event first and Ben’s second, without the application ever having to sort anything. LLEN confirms the feed currently holds 3 entries.
Example 3: Reading ranges with negative indexes
Negative indexes let you grab “the last N” without first calling LLEN to compute an offset, and asking for more than exists is safe.
RPUSH log:logins "10:01" "10:15" "10:42" "11:05" "11:30"
LRANGE log:logins -3 -1
LRANGE log:logins -100 100
(integer) 5
1) "10:42"
2) "11:05"
3) "11:30"
1) "10:01"
2) "10:15"
3) "10:42"
4) "11:05"
5) "11:30"
LRANGE log:logins -3 -1 counts three positions back from the tail, returning the three most recent login timestamps without knowing the list’s length. LRANGE log:logins -100 100 requests indexes far outside the list’s bounds on both ends, but instead of erroring, Redis clamps the range to what actually exists and returns the entire list.
How It Works Step by Step
When you run LPUSH key value, Redis: (1) looks up key in the main keyspace dictionary; (2) if the key doesn’t exist, creates a new empty list object encoded as a listpack; (3) inserts value at the head of the underlying structure — an O(1) operation because the head is a direct pointer, not something that requires scanning; (4) if the list has grown past the configured listpack size limit, converts the encoding to a quicklist (a linked list of listpack nodes) so it stays efficient at larger sizes; and (5) returns the new length of the list as an integer reply. RPUSH follows the identical process at the tail instead of the head.
When you run LRANGE key start stop, Redis: (1) looks up the list; (2) normalizes negative indexes by adding the list’s length (so -1 becomes length - 1); (3) clamps both indexes to the valid [0, length-1] range; (4) walks the underlying quicklist/listpack nodes from the start offset, collecting elements until it passes stop; and (5) returns them as an array reply in the same left-to-right order they’re stored in. Because step 4 has to walk from the start offset, requesting a range deep into a very large list (a large S) costs more than requesting one near the head — this is the O(S+N) behind the time complexity above.
Common Mistakes
Mistake 1: Treating a non-list key as a list
Every Redis key has exactly one type, and calling a list command on a key holding a string returns a type error instead of doing anything useful.
SET user:1001:name "Ada"
LPUSH user:1001:name "oops"
OK
(error) WRONGTYPE Operation against a key holding the wrong kind of value
The fix is simply to use a distinct key for the list — for example user:1001:tags — rather than reusing a key that already holds a string, hash, or other type.
Mistake 2: Assuming multiple LPUSH arguments land in the order you typed them
Look again at Example 1: pushing several values in a single LPUSH call inserts them one at a time at the head, so the last argument ends up closest to the front — the argument order is effectively reversed relative to final list order. If you run LPUSH mylist "a" "b" "c", the resulting list is c, b, a, not a, b, c. If you need the values to end up in the same left-to-right order you wrote them, either reverse the argument list before pushing or use RPUSH instead, which preserves argument order at the tail.
Mistake 3: Letting an activity-feed list grow forever
It’s easy to LPUSH new events onto a feed and never remove old ones, silently leaking memory as the list grows without bound. Pair pushes with LTRIM to cap the list at a fixed size.
RPUSH recent:events "e1" "e2" "e3" "e4" "e5"
LTRIM recent:events 0 2
LRANGE recent:events 0 -1
(integer) 5
OK
1) "e1"
2) "e2"
3) "e3"
LTRIM recent:events 0 2 keeps only indexes 0 through 2 (the first three elements) and discards everything else, which is exactly how you’d cap a feed to, say, its 100 most recent entries after every LPUSH.
Best Practices
- Use
RPUSHto enqueue and (a later lesson’s)LPOPto dequeue for FIFO queues; combineLPUSHwithLPOPfor a LIFO stack. - Always cap unbounded lists — like activity feeds or logs — with
LTRIMright after pushing, so memory usage stays predictable. - Remember that multiple values passed to one
LPUSHcall are inserted in reverse order relative to how you typed them; useRPUSHor reverse your argument list if left-to-right order matters. - Avoid calling
LRANGE key 0 -1on very large lists in hot code paths — it’s O(N) on the full list; paginate with explicitstart/stopchunks instead. - Check
LLENbefore doing a largeLRANGEso you know roughly how much data you’re about to pull back. - Use distinct, namespaced key names (
queue:emails,feed:user:42) so list keys never collide with keys of other types.
Practice Exercises
- Build a “last 5 searches” feature: for a given user, push each new search term with
LPUSHontosearch:history:<user_id>, then useLTRIMso the list never holds more than 5 entries. Verify the end state withLRANGE. - Implement a simple print queue: use
RPUSHto add job names toprintqueue:office2as they arrive, then useLRANGEto inspect the pending jobs without removing any of them. - Given a list of daily temperature readings appended with
RPUSH, useLRANGEwith negative indexes to retrieve just the last 3 readings without first callingLLENto figure out the list’s length.
Summary
LPUSHadds one or more elements to the head of a list;RPUSHadds to the tail. Both are O(1) per element.LRANGE key start stopreads an inclusive range by index, accepts negative indexes counted from the tail, and is O(S+N).- Lists are stored internally as listpack (small lists) or quicklist (larger lists), so pushing to either end never requires shifting existing elements.
- A key holding a list rejects non-list commands, and vice versa, with a
WRONGTYPEerror. - Multiple values passed to a single
LPUSHcall are inserted in reverse order relative to how you typed them. - Pair pushes with
LTRIMto keep capped lists, like activity feeds, from growing forever.
