Skip to content

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 from SLOWLOG (threshold 0) on this machine; treat them as orders of magnitude, not benchmarks.

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:

cli/lists-hashes-lists-1.redis
127.0.0.1:6379> RPUSH tasks "buy milk"
(integer) 1
127.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 bytes
after 256 items of 30 bytes: quicklist, MEMORY USAGE 8357 bytes
2 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 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.

cli/lists-hashes-lists-2.redis
127.0.0.1:6379> RPUSH tasks "write code" "review PR"
(integer) 3
127.0.0.1:6379> LPUSH tasks "urgent: fix bug"
(integer) 4
127.0.0.1:6379> LPUSH order a b c
(integer) 3
127.0.0.1:6379> LRANGE order 0 -1
1) "c"
2) "b"
3) "a"
127.0.0.1:6379> LPUSHX nolist a
(integer) 0
127.0.0.1:6379> EXISTS nolist
(integer) 0

LPUSH 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 -1

Once you have a list, these three commands cover most read patterns:

  • LRANGE key start stop — returns elements from index start to stop (inclusive, 0-based). Use -1 for the last element, -2 for 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).
cli/lists-hashes-lists-3.redis
127.0.0.1:6379> LRANGE tasks 0 -1
1) "urgent: fix bug"
2) "buy milk"
3) "write code"
4) "review PR"
127.0.0.1:6379> LLEN tasks
(integer) 4
127.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.

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 listServer time
LINDEX list 500000 (the middle)15 µs
LRANGE list 500000 5000998 µs
RPUSH / LPOP at the ends3 µ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 ms
LRANGE b1:big 0 19 server 8 us client: 20 items in 0.8 ms

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 to count occurrences scanning from head to tail
  • count < 0 — remove up to |count| occurrences scanning from tail to head
  • count = 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:

cli/lists-hashes-lists-4.redis
127.0.0.1:6379> LSET tasks 1 "buy oat milk"
OK
127.0.0.1:6379> LSET tasks 10 "nope"
(error) ERR index out of range
127.0.0.1:6379> LRANGE tasks 0 -1
1) "urgent: fix bug"
2) "buy oat milk"
3) "write code"
4) "review PR"
127.0.0.1:6379> RPUSH tasks "write code"
(integer) 5
127.0.0.1:6379> LREM tasks 0 "write code"
(integer) 2
127.0.0.1:6379> LPOS tasks "review PR"
(integer) 2
127.0.0.1:6379> LPOS tasks "nothere"
(nil)
127.0.0.1:6379> RPUSH dup a b a c a
(integer) 5
127.0.0.1:6379> LPOS dup a
(integer) 0
127.0.0.1:6379> LPOS dup a RANK 2
(integer) 2
127.0.0.1:6379> LPOS dup a COUNT 0
1) (integer) 0
2) (integer) 2
3) (integer) 4
127.0.0.1:6379> LINSERT tasks BEFORE "review PR" "test it"
(integer) 4
127.0.0.1:6379> LINSERT tasks AFTER "nothere" "x"
(integer) -1
127.0.0.1:6379> LRANGE tasks 0 -1
1) "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 -1

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.

cli/lists-hashes-lists-5.redis
127.0.0.1:6379> DEL recent
(integer) 0
127.0.0.1:6379> RPUSH recent "page-A" "page-B" "page-C" "page-D" "page-E"
(integer) 5
127.0.0.1:6379> LTRIM recent 0 2
OK
127.0.0.1:6379> LRANGE recent 0 -1
1) "page-A"
2) "page-B"
3) "page-C"
127.0.0.1:6379> LTRIM recent 5 10
OK
127.0.0.1:6379> EXISTS recent
(integer) 0
DEL recent
RPUSH recent "page-A" "page-B" "page-C" "page-D" "page-E"
LRANGE recent 0 -1
LTRIM recent 0 2
LRANGE recent 0 -1

LMPOP — 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”:

cli/lists-hashes-lists-6.redis
127.0.0.1:6379> RPUSH q1 a b c
(integer) 3
127.0.0.1:6379> RPUSH q2 x y
(integer) 2
127.0.0.1:6379> LMPOP 2 q1 q2 LEFT COUNT 2
1) "q1"
2) 1) "a"
2) "b"
127.0.0.1:6379> LMPOP 2 q1 q2 RIGHT
1) "q1"
2) 1) "c"
127.0.0.1:6379> LMPOP 2 q1 q2 LEFT
1) "q2"
2) 1) "x"
127.0.0.1:6379> LMPOP 2 q1 q2 LEFT
1) "q2"
2) 1) "y"
127.0.0.1:6379> EXISTS q1 q2
(integer) 0

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:

src/lists-hashes-lists-feed.ts
// @run
import { 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)); // 50
console.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 list
const 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 null
await 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.

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 everything
res.json(all.slice(page * 20, page * 20 + 20));
});
// writer side
await redis.lpush('activity:user:42', JSON.stringify(event)); // never trimmed
Show 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 window
const items = await redis.lrange('activity:user:42', page * 20, page * 20 + 19);
// writer: newest first, keep the latest 500, one MULTI
await 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).

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.

ChoiceBenefitCost
Redis List (listpack, then a quicklist of listpacks)O(1) push/pop at both ends — good fit for queues, stacks, feeds; compact for small listsOperations that look at every element (LREM, LPOS, LINSERT) are O(N): 13 ms at 1M elements
LPUSH + LTRIM for a recent-items feedKeeps a fixed-size list automatically, no separate cleanup jobYou must call LTRIM after every push (put both in a MULTI) — forget it and the list grows without bound
LRANGE key 0 -1One call, whole listO(N) work and O(N) bytes: 35 ms server + 490 ms transfer at 1M items — page with LRANGE start stop
LREM to remove by valueDeletes an item you don’t know the index ofScans the whole list, O(N) — not a good fit for a large list with frequent deletes
  • “A Redis list is a doubly linked list.” Small lists are one listpack; large ones are a linked list of listpacks (quicklist). OBJECT ENCODING tells you which.
  • “LINDEX is 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; EXISTS is 0.
  • “LPUSH a b c gives a b c.” It gives c b a.
  • Fetching the whole list to show one page — use LRANGE key start stop with the page window; LRANGE 0 -1 on a big list blocks the server and floods the client.
  • Using LPUSH for an activity feed without a following LTRIM — the list grows without bound and consumes memory, when you only ever needed the last N items.
  • Expecting LSET to behave like “insert at index” — it only overwrites an element that already exists there; if the index doesn’t exist, you get ERR index out of range. LINSERT inserts relative to a value.
  • Ignoring LINSERT’s -1 — a missing pivot is not an error, just a -1 reply.

Real-world example

Redis’s own documentation describes lists as the type for queues and for capped “latest items” collections, and shows LPUSH + LTRIM as the recipe for keeping only the newest N (Redis lists).

What does LRANGE tasks 0 -1 return?
Which push command adds an element to the HEAD of a list?
What does LREM key 0 val do?
After LTRIM mylist 0 2, how many elements remain?
What does OBJECT ENCODING show for a small list, and what does it become when the list grows past about 8 KB?
Which list operation is the expensive one on a 1,000,000-element list?