Lists
Verified on Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. Timings are server-side microseconds fromSLOWLOG(threshold 0) on this machine; treat them as orders of magnitude, not benchmarks.
What is a Redis List?
Section titled “What is a Redis List?”A Redis List is an ordered collection of strings. Pushing and popping at either end is O(1), which makes lists a natural fit for queues, stacks, and activity feeds. Reaching into the middle costs more, so lists shine when you mostly work at the head or tail.
How is it stored? Older material (including earlier versions of this lesson) says “doubly linked list”. On 8.10.2 that is only half the story. A small list is one compact block, a listpack; a big one is a quicklist, a linked list whose nodes are listpacks:
127.0.0.1:6379> RPUSH tasks "buy milk"(integer) 1127.0.0.1:6379> OBJECT ENCODING tasks"listpack"The switch is driven by list-max-listpack-size (default -2, meaning each node holds at most 8 KB). I pushed 30-byte items one at a time and asked for the encoding after each push:
after 1 items of 30 bytes: listpack, MEMORY USAGE 62 bytesafter 256 items of 30 bytes: quicklist, MEMORY USAGE 8357 bytes2 items x 100 bytes -> listpack+ one 9000-byte item -> quicklist (a single large element also forces it)So the boundary is a size (≈ 8 KB), not a count. You do not choose the encoding, but it explains the performance numbers below: an element is found by walking nodes, and there are few nodes.
LPUSH and RPUSH
Section titled “LPUSH and RPUSH”LPUSH key val [val ...] prepends one or more values to the head of the list. When you push multiple values at once, the last argument ends up at the head — each value is pushed individually from left to right.
RPUSH key val [val ...] appends one or more values to the tail.
127.0.0.1:6379> RPUSH tasks "write code" "review PR"(integer) 3127.0.0.1:6379> LPUSH tasks "urgent: fix bug"(integer) 4127.0.0.1:6379> LPUSH order a b c(integer) 3127.0.0.1:6379> LRANGE order 0 -11) "c"2) "b"3) "a"127.0.0.1:6379> LPUSHX nolist a(integer) 0127.0.0.1:6379> EXISTS nolist(integer) 0LPUSH order a b c produced c b a — proof that values are pushed one by one, so the last argument lands at the head. LPUSHX/RPUSHX push only if the list already exists (and return 0 otherwise). Note that a Redis list is never “empty”: a list with zero elements does not exist — Redis deletes the key when the last element is popped.
RPUSH tasks "buy milk"
RPUSH tasks "write code" "review PR"
LPUSH tasks "urgent: fix bug"
LRANGE tasks 0 -1
LLEN tasks
LINDEX tasks 0
LINDEX tasks -1LRANGE, LLEN, and LINDEX
Section titled “LRANGE, LLEN, and LINDEX”Once you have a list, these three commands cover most read patterns:
LRANGE key start stop— returns elements from indexstarttostop(inclusive, 0-based). Use-1for the last element,-2for second-to-last, and so on.LLEN key— returns the number of elements in the list.LINDEX key index— returns the single element at the given index. Negative indices count from the tail; an index past the end gives(nil).
127.0.0.1:6379> LRANGE tasks 0 -11) "urgent: fix bug"2) "buy milk"3) "write code"4) "review PR"127.0.0.1:6379> LLEN tasks(integer) 4127.0.0.1:6379> LINDEX tasks 0"urgent: fix bug"127.0.0.1:6379> LINDEX tasks -1"review PR"127.0.0.1:6379> LINDEX tasks 99(nil)LRANGE tasks 0 -1 is the idiomatic way to fetch every element of a small list.
How slow is “O(N) in the middle”?
Section titled “How slow is “O(N) in the middle”?”The docs call LINDEX, LREM, LPOS and LINSERT O(N). I built a list of 1,000,000 strings (value-<n>, 14 MB, quicklist) and read the server-side time for each:
| Command on the 1M-element list | Server time |
|---|---|
LINDEX list 500000 (the middle) | 15 µs |
LRANGE list 500000 500099 | 8 µs |
RPUSH / LPOP at the ends | 3 µs / 9 µs |
LREM list 0 nothere (full scan) | 13,092 µs (13 ms) |
LPOS list nothere (full scan) | 13,152 µs |
So the classic warning is real but needs a sharper edge. Jumping to an index is cheap here, because the walk is over a few hundred nodes, not a million pointers. What hurts is anything that must look at every element — LREM, LPOS, LINSERT ... pivot — 13 ms of frozen server at a million elements, linear beyond that. And the biggest trap is LRANGE key 0 -1 on a big list:
LRANGE b1:big 0 -1 server 35,370 us client: 1,000,000 items in 490 msLRANGE b1:big 0 19 server 8 us client: 20 items in 0.8 msLSET, LREM, LPOS and LINSERT
Section titled “LSET, LREM, LPOS and LINSERT”LSET key index value overwrites the element at a specific index. The list must already contain an element at that position, otherwise Redis returns an error.
LREM key count value removes occurrences of value:
count > 0— remove up tocountoccurrences scanning from head to tailcount < 0— remove up to|count|occurrences scanning from tail to headcount = 0— remove all occurrences
LPOS finds the index of a value (6.0.6+), RANK skips matches, COUNT 0 returns all; LINSERT inserts relative to a pivot value:
127.0.0.1:6379> LSET tasks 1 "buy oat milk"OK127.0.0.1:6379> LSET tasks 10 "nope"(error) ERR index out of range127.0.0.1:6379> LRANGE tasks 0 -11) "urgent: fix bug"2) "buy oat milk"3) "write code"4) "review PR"127.0.0.1:6379> RPUSH tasks "write code"(integer) 5127.0.0.1:6379> LREM tasks 0 "write code"(integer) 2127.0.0.1:6379> LPOS tasks "review PR"(integer) 2127.0.0.1:6379> LPOS tasks "nothere"(nil)127.0.0.1:6379> RPUSH dup a b a c a(integer) 5127.0.0.1:6379> LPOS dup a(integer) 0127.0.0.1:6379> LPOS dup a RANK 2(integer) 2127.0.0.1:6379> LPOS dup a COUNT 01) (integer) 02) (integer) 23) (integer) 4127.0.0.1:6379> LINSERT tasks BEFORE "review PR" "test it"(integer) 4127.0.0.1:6379> LINSERT tasks AFTER "nothere" "x"(integer) -1127.0.0.1:6379> LRANGE tasks 0 -11) "urgent: fix bug"2) "buy oat milk"3) "test it"4) "review PR"LREM tasks 0 "write code" removed 2 (there were two copies). LINSERT returns the new length, or -1 if the pivot was not found (no error, easy to miss).
RPUSH tasks "buy milk"
RPUSH tasks "write code" "review PR"
LPUSH tasks "urgent: fix bug"
LSET tasks 1 "buy oat milk"
LRANGE tasks 0 -1
RPUSH tasks "write code"
LREM tasks 0 "write code"
LRANGE tasks 0 -1LTRIM — keep only a slice
Section titled “LTRIM — keep only a slice”LTRIM key start stop modifies the list in place, retaining only the elements in [start, stop] and discarding everything outside that range. It returns OK, making it safe to call after every write. A range that keeps nothing removes the key.
127.0.0.1:6379> DEL recent(integer) 0127.0.0.1:6379> RPUSH recent "page-A" "page-B" "page-C" "page-D" "page-E"(integer) 5127.0.0.1:6379> LTRIM recent 0 2OK127.0.0.1:6379> LRANGE recent 0 -11) "page-A"2) "page-B"3) "page-C"127.0.0.1:6379> LTRIM recent 5 10OK127.0.0.1:6379> EXISTS recent(integer) 0DEL recent
RPUSH recent "page-A" "page-B" "page-C" "page-D" "page-E"
LRANGE recent 0 -1
LTRIM recent 0 2
LRANGE recent 0 -1LMPOP — pop from the first non-empty list (Redis 7.0+)
Section titled “LMPOP — pop from the first non-empty list (Redis 7.0+)”LMPOP numkeys key [key ...] LEFT|RIGHT [COUNT n] pops up to n elements from the first non-empty list among the keys you name and tells you which key it used — a one-command “take work from whichever queue has some”:
127.0.0.1:6379> RPUSH q1 a b c(integer) 3127.0.0.1:6379> RPUSH q2 x y(integer) 2127.0.0.1:6379> LMPOP 2 q1 q2 LEFT COUNT 21) "q1"2) 1) "a" 2) "b"127.0.0.1:6379> LMPOP 2 q1 q2 RIGHT1) "q1"2) 1) "c"127.0.0.1:6379> LMPOP 2 q1 q2 LEFT1) "q2"2) 1) "x"127.0.0.1:6379> LMPOP 2 q1 q2 LEFT1) "q2"2) 1) "y"127.0.0.1:6379> EXISTS q1 q2(integer) 0From Node.js: a bounded feed
Section titled “From Node.js: a bounded feed”The same commands through ioredis. lpush + ltrim in a multi(), a page is a slice (lrange(key, 10, 19)), and lmpop returns [key, items] or null:
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const key = 'b1:feed:user:42';await redis.del(key);
// bounded feed: newest first, keep the latest 50. LPUSH + LTRIM in one MULTI so a crash can't skip the trim.async function addEvent(event: string) { await redis.multi().lpush(key, event).ltrim(key, 0, 49).exec();}for (let i = 1; i <= 120; i++) await addEvent(`event-${i}`);console.log('length after 120 events:', await redis.llen(key)); // 50console.log('newest, oldest kept:', await redis.lindex(key, 0), await redis.lindex(key, -1)); // event-120 event-71
// a page is a slice, not the whole listconst page2 = await redis.lrange(key, 10, 19);console.log('page 2:', page2.length, 'items, first', page2[0]); // 10 items, first event-110
// LMPOP: take up to 2 items from the first non-empty list, or nullawait redis.del('b1:qa', 'b1:qb');await redis.rpush('b1:qb', 'job-1', 'job-2', 'job-3');console.log(await redis.lmpop(2, 'b1:qa', 'b1:qb', 'LEFT', 'COUNT', 2)); // [ 'b1:qb', [ 'job-1', 'job-2' ] ]console.log(await redis.lmpop(2, 'b1:qa', 'b1:qa2', 'LEFT')); // null
await redis.del(key, 'b1:qa', 'b1:qb');await redis.quit();Real output: 50, event-120 event-71, 10 items, first event-110, [ 'b1:qb', [ 'job-1', 'job-2' ] ], null.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant builds the “recent activity” API for a profile page, 20 items per page:
🐛 Spot the bug
app.get('/activity', async (req, res) => {const page = Number(req.query.page ?? 0);const all = await redis.lrange('activity:user:42', 0, -1); // fetch everythingres.json(all.slice(page * 20, page * 20 + 20));});
// writer sideawait redis.lpush('activity:user:42', JSON.stringify(event)); // never trimmedShow the answer
Two problems that feed each other. The writer never trims, so the list grows forever; and the reader downloads the whole list to show 20 items. On a 1,000,000-item list we measured LRANGE 0 -1 at 35 ms of frozen command thread and 490 ms to ship the payload to the client — per page view — against 8 µs and 0.8 ms for LRANGE 0 19. The fix is to ask Redis for the slice and to bound the list at write time:
// reader: LRANGE with the page windowconst items = await redis.lrange('activity:user:42', page * 20, page * 20 + 19);// writer: newest first, keep the latest 500, one MULTIawait redis.multi().lpush('activity:user:42', JSON.stringify(event)).ltrim('activity:user:42', 0, 499).exec();The bounded, paged version is the runnable lists-hashes-lists-feed.ts above (50 items kept, page 2 = LRANGE 10 19, output shown).
Mental Model
Section titled “Mental Model”A list is a queue of strings with two open ends: pushing and popping at the ends is constant work; asking “where in the middle?” means walking, and asking “is it anywhere?” (LREM, LPOS) means looking at everything. Its size is your responsibility: nothing trims it for you. Design the operations so the hot path touches only the ends and a bounded window.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Redis List (listpack, then a quicklist of listpacks) | O(1) push/pop at both ends — good fit for queues, stacks, feeds; compact for small lists | Operations that look at every element (LREM, LPOS, LINSERT) are O(N): 13 ms at 1M elements |
LPUSH + LTRIM for a recent-items feed | Keeps a fixed-size list automatically, no separate cleanup job | You must call LTRIM after every push (put both in a MULTI) — forget it and the list grows without bound |
LRANGE key 0 -1 | One call, whole list | O(N) work and O(N) bytes: 35 ms server + 490 ms transfer at 1M items — page with LRANGE start stop |
LREM to remove by value | Deletes an item you don’t know the index of | Scans the whole list, O(N) — not a good fit for a large list with frequent deletes |
Common Misconceptions
Section titled “Common Misconceptions”- “A Redis list is a doubly linked list.” Small lists are one listpack; large ones are a linked list of listpacks (quicklist).
OBJECT ENCODINGtells you which. - “
LINDEXis slow.” In the middle of a 1M list it took 15 µs. The slow ones are the scans:LREM,LPOS,LINSERT. - “An empty list still exists.” Popping the last element deletes the key;
EXISTSis0. - “
LPUSH a b cgivesa b c.” It givesc b a.
Common mistakes
Section titled “Common mistakes”- Fetching the whole list to show one page — use
LRANGE key start stopwith the page window;LRANGE 0 -1on a big list blocks the server and floods the client. - Using
LPUSHfor an activity feed without a followingLTRIM— the list grows without bound and consumes memory, when you only ever needed the last N items. - Expecting
LSETto behave like “insert at index” — it only overwrites an element that already exists there; if the index doesn’t exist, you getERR index out of range.LINSERTinserts relative to a value. - Ignoring
LINSERT’s-1— a missing pivot is not an error, just a-1reply.
Real-world example
Redis’s own documentation describes lists as the type for queues and for capped “latest items” collections, and shows
LPUSH+LTRIMas the recipe for keeping only the newest N (Redis lists).