Redis Core Concepts: Lists

Redis Lists are ordered collections of strings. Unlike programming language arrays, Redis Lists are optimized for adding and removing elements from either the head (left) or the tail (right) of the list very efficiently, making them perfect for implementing queues, stacks, or simple chronological timelines.

In this chapter, we’ll cover:

  • The nature and applications of Redis Lists.
  • Commands for adding elements to lists (LPUSH, RPUSH).
  • Commands for removing elements from lists (LPOP, RPOP).
  • Commands for retrieving elements from lists (LRANGE).
  • Trimming lists (LTRIM) and other useful list operations.
  • Blocking list operations for robust queues.

Understanding Redis Lists

A Redis List can be visualized as a doubly-linked list of strings.

key -> [element1, element2, element3, ...]

Key characteristics:

  • Ordered: Elements are inserted and retrieved in a specific order.
  • Duplicates allowed: Unlike Sets, Lists can contain multiple identical elements.
  • Efficient head/tail operations: Adding/removing elements from the ends (LPUSH, RPUSH, LPOP, RPOP) is an O(1) operation.
  • Less efficient middle operations: Accessing elements by index or inserting in the middle is O(N).
  • Max length: Lists can grow up to 2^32 - 1 elements (over 4 billion).

Basic List Commands (Push & Pop)

1. LPUSH key element [element ...] (Left Push)

Adds one or more elements to the head (left side) of the list stored at key. If key does not exist, it is created as a new list. Returns the new length of the list.

2. RPUSH key element [element ...] (Right Push)

Adds one or more elements to the tail (right side) of the list stored at key. If key does not exist, it is created as a new list. Returns the new length of the list.

Node.js Example:

// redis-lists.js
const Redis = require('ioredis');
const redis = new Redis();

async function pushExample() {
  try {
    const chatRoomKey = 'chatroom:general:messages';

    // RPUSH: Add messages to the end (tail) of the list
    let length = await redis.rpush(chatRoomKey, 'User1: Hello!', 'User2: Hi there!');
    console.log(`RPUSH ${chatRoomKey} -> New length: ${length}`); // Output: New length: 2

    // LPUSH: Add a new message to the beginning (head)
    length = await redis.lpush(chatRoomKey, 'System: Welcome new users!');
    console.log(`LPUSH ${chatRoomKey} -> New length: ${length}`); // Output: New length: 3

    // See the current state of the list (from left to right)
    let messages = await redis.lrange(chatRoomKey, 0, -1);
    console.log(`Current messages: ${JSON.stringify(messages)}`);
    // Output: ["System: Welcome new users!", "User1: Hello!", "User2: Hi there!"]

    const recentActionsKey = 'user:123:recent_actions';
    await redis.rpush(recentActionsKey, 'Logged in', 'Viewed profile', 'Updated settings');
    console.log(`Recent actions: ${await redis.lrange(recentActionsKey, 0, -1)}`);
    // Output: ["Logged in", "Viewed profile", "Updated settings"]

  } catch (err) {
    console.error('Error in pushExample:', err);
  }
}

// pushExample().then(() => redis.quit());

Python Example:

# redis_lists.py
import redis

r = redis.Redis(host='localhost', port=6379, db=0)

def push_example():
    try:
        chat_room_key = 'chatroom:dev:messages'

        # RPUSH: Add messages to the end (tail) of the list
        length = r.rpush(chat_room_key, 'Dev1: Starting coding', 'Dev2: Reviewing PR')
        print(f"RPUSH {chat_room_key} -> New length: {length}") # Output: New length: 2

        # LPUSH: Add a new message to the beginning (head)
        length = r.lpush(chat_room_key, 'System: Sprint begins!')
        print(f"LPUSH {chat_room_key} -> New length: {length}") # Output: New length: 3

        # See the current state of the list (from left to right)
        messages = [msg.decode('utf-8') for msg in r.lrange(chat_room_key, 0, -1)]
        print(f"Current messages: {messages}")
        # Output: ['System: Sprint begins!', 'Dev1: Starting coding', 'Dev2: Reviewing PR']

        todo_list_key = 'user:456:todo'
        r.rpush(todo_list_key, 'Buy groceries', 'Walk the dog', 'Finish Redis chapter')
        print(f"To-do list: {[item.decode('utf-8') for item in r.lrange(todo_list_key, 0, -1)]}")

    except Exception as e:
        print(f"Error in push_example: {e}")

# push_example()
# r.close()

3. LPOP key (Left Pop)

Removes and returns the first (leftmost) element of the list stored at key.

4. RPOP key (Right Pop)

Removes and returns the last (rightmost) element of the list stored at key.

Node.js Example:

// ... (previous setup)
async function popExample() {
  try {
    const taskQueueKey = 'background:tasks';
    await redis.rpush(taskQueueKey, 'Task A', 'Task B', 'Task C');
    console.log(`Queue before pops: ${await redis.lrange(taskQueueKey, 0, -1)}`);

    let poppedTask = await redis.lpop(taskQueueKey); // Remove from head (left)
    console.log(`LPOP ${taskQueueKey} -> Popped: ${poppedTask}`); // Output: Task A
    console.log(`Queue after LPOP: ${await redis.lrange(taskQueueKey, 0, -1)}`); // Output: ["Task B", "Task C"]

    poppedTask = await redis.rpop(taskQueueKey); // Remove from tail (right)
    console.log(`RPOP ${taskQueueKey} -> Popped: ${poppedTask}`); // Output: Task C
    console.log(`Queue after RPOP: ${await redis.lrange(taskQueueKey, 0, -1)}`); // Output: ["Task B"]

    let emptyPop = await redis.lpop(taskQueueKey); // Pop the last element
    console.log(`LPOP ${taskQueueKey} -> Popped: ${emptyPop}`); // Output: Task B
    emptyPop = await redis.lpop(taskQueueKey); // Pop from empty list
    console.log(`LPOP ${taskQueueKey} (empty) -> Popped: ${emptyPop}`); // Output: null

  } catch (err) {
    console.error('Error in popExample:', err);
  }
}

// popExample().then(() => redis.quit());

Python Example:

# ... (previous setup)
def pop_example():
    try:
        eventStreamKey = 'user:activity:stream'
        r.rpush(eventStreamKey, 'Click:Home', 'View:Product', 'Add:Cart')
        print(f"Stream before pops: {[e.decode('utf-8') for e in r.lrange(eventStreamKey, 0, -1)]}")

        poppedEvent = r.lpop(eventStreamKey)
        print(f"LPOP {eventStreamKey} -> Popped: {poppedEvent.decode('utf-8')}") # Output: Click:Home
        print(f"Stream after LPOP: {[e.decode('utf-8') for e in r.lrange(eventStreamKey, 0, -1)]}")

        poppedEvent = r.rpop(eventStreamKey)
        print(f"RPOP {eventStreamKey} -> Popped: {poppedEvent.decode('utf-8')}") # Output: Add:Cart
        print(f"Stream after RPOP: {[e.decode('utf-8') for e in r.lrange(eventStreamKey, 0, -1)]}")

        emptyPop = r.lpop(eventStreamKey)
        print(f"LPOP {eventStreamKey} -> Popped: {emptyPop.decode('utf-8')}") # Output: View:Product
        emptyPop = r.lpop(eventStreamKey)
        print(f"LPOP {eventStreamKey} (empty) -> Popped: {emptyPop}") # Output: None

    except Exception as e:
        print(f"Error in pop_example: {e}")

# pop_example()
# r.close()

Retrieving and Trimming Lists

1. LRANGE key start stop (Left Range)

Returns the specified elements of the list stored at key. start and stop are zero-based indices. Negative indices can be used to offset from the end of the list (e.g., -1 is the last element). To get all elements, use LRANGE key 0 -1.

2. LLEN key (List Length)

Returns the length of the list stored at key.

3. LTRIM key start stop (List Trim)

Trims an existing list so that it will contain only the specified range of elements. Useful for capping list size (e.g., keeping only the 100 most recent items).

Node.js Example:

// ... (previous setup)
async function rangeAndTrimExample() {
  try {
    const newsFeedKey = 'user:456:news_feed';
    await redis.del(newsFeedKey); // Clean up any previous data
    await redis.rpush(newsFeedKey, 'Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5');
    console.log(`Initial feed: ${await redis.lrange(newsFeedKey, 0, -1)}`); // Output: ["Item 1", ..., "Item 5"]
    console.log(`Length: ${await redis.llen(newsFeedKey)}`); // Output: 5

    // Get the first 3 items
    let firstThree = await redis.lrange(newsFeedKey, 0, 2);
    console.log(`First 3 items: ${firstThree}`); // Output: ["Item 1", "Item 2", "Item 3"]

    // Get the last 2 items
    let lastTwo = await redis.lrange(newsFeedKey, -2, -1);
    console.log(`Last 2 items: ${lastTwo}`); // Output: ["Item 4", "Item 5"]

    // Trim the list to keep only the 3 most recent items (rightmost)
    await redis.ltrim(newsFeedKey, 2, -1); // Keep elements from index 2 to the end
    console.log(`Feed after LTRIM (keep last 3): ${await redis.lrange(newsFeedKey, 0, -1)}`);
    // Output: ["Item 3", "Item 4", "Item 5"]
    console.log(`New length: ${await redis.llen(newsFeedKey)}`); // Output: 3

  } catch (err) {
    console.error('Error in rangeAndTrimExample:', err);
  } finally {
    await redis.del('news_feed:456'); // Clean up
  }
}

// rangeAndTrimExample().then(() => redis.quit());

Python Example:

# ... (previous setup)
def range_and_trim_example():
    try:
        recentSearchesKey = 'user:789:recent_searches'
        r.delete(recentSearchesKey)
        r.rpush(recentSearchesKey, 'Redis', 'Python', 'Node.js', 'Docker', 'Kubernetes')
        print(f"Initial searches: {[s.decode('utf-8') for s in r.lrange(recentSearchesKey, 0, -1)]}")
        print(f"Length: {r.llen(recentSearchesKey)}") # Output: 5

        # Get the middle 3 items
        middleThree = [s.decode('utf-8') for s in r.lrange(recentSearhesKey, 1, 3)]
        print(f"Middle 3 items: {middleThree}") # Output: ['Python', 'Node.js', 'Docker']

        # Trim the list to keep only the 2 most recent items (rightmost)
        # Note: LTRIM works by specifying start and end indices of the *portion to keep*
        # To keep the last 2, if list length is N, you keep from N-2 to N-1
        current_length = r.llen(recentSearchesKey)
        r.ltrim(recentSearchesKey, current_length - 2, current_length - 1)
        print(f"Searches after LTRIM (keep last 2): {[s.decode('utf-8') for s in r.lrange(recentSearchesKey, 0, -1)]}")
        # Output: ["Docker", "Kubernetes"]
        print(f"New length: {r.llen(recentSearchesKey)}") # Output: 2

    except Exception as e:
        print(f"Error in range_and_trim_example: {e}")
    finally:
        r.delete('user:789:recent_searches')

# range_and_trim_example()
# r.close()

Blocking List Operations (BLPOP, BRPOP) for Robust Queues

When building message queues or task queues, you often need consumers to wait for new elements to appear if the list is empty, rather than polling constantly. Redis offers blocking pop operations for this purpose.

  • BLPOP key [key ...] timeout: Removes and returns the first element of the list stored at the first key that is non-empty. If all specified lists are empty, it blocks for timeout seconds (0 for indefinite blocking).
  • BRPOP key [key ...] timeout: Same as BLPOP but removes from the tail (right).

These commands are crucial for building reliable producer-consumer patterns. When a consumer calls BLPOP on an empty list, it waits until an element is pushed to that list by a producer, or the timeout is reached.

Node.js Example (Conceptual for BLPOP/BRPOP):

This example requires two separate scripts or processes to demonstrate. One “producer” script and one “consumer” script.

// producer.js
const Redis = require('ioredis');
const redis = new Redis();
const queueKey = 'my:blocking:queue';

async function producer() {
  console.log('Producer started. Pushing messages every 2 seconds...');
  let i = 0;
  setInterval(async () => {
    const message = `Message ${i++} - ${Date.now()}`;
    await redis.rpush(queueKey, message);
    console.log(`Pushed: "${message}" to ${queueKey}`);
  }, 2000);

  // Allow enough time for messages to be pushed/consumed
  setTimeout(() => {
    redis.quit();
    console.log('Producer finished.');
  }, 10000); // Run for 10 seconds
}

// producer();
// consumer.js
const Redis = require('ioredis');
const redis = new Redis(); // Separate connection for consumer
const queueKey = 'my:blocking:queue';

async function consumer() {
  console.log('Consumer started. Waiting for messages...');
  while (true) {
    // BLPOP blocks until an element is available or timeout (0 for indefinite)
    // Returns an array: [key, element]
    const result = await redis.blpop(queueKey, 0); // Block indefinitely
    if (result) {
      const [key, message] = result;
      console.log(`Consumed: "${message}" from ${key}`);
    } else {
      console.log('No messages, timed out (should not happen with timeout 0).');
    }
  }
}

// consumer();
// To run:
// 1. Open one terminal: node producer.js
// 2. Open another terminal: node consumer.js
// You will see the consumer immediately picking up messages as the producer pushes them.
// Hit Ctrl+C in both terminals to stop.

Python Example (Conceptual for BLPOP/BRPOP):

# producer.py
import redis
import time

r = redis.Redis(host='localhost', port=6379, db=0)
queue_key = 'my:blocking:queue:py'

def producer_py():
    print("Python Producer started. Pushing messages every 2 seconds...")
    i = 0
    while True:
        message = f"Message {i} - {int(time.time() * 1000)}"
        r.rpush(queue_key, message)
        print(f"Pushed: \"{message}\" to {queue_key}")
        i += 1
        time.sleep(2)

# producer_py()
# r.close()
# consumer.py
import redis
import time

r = redis.Redis(host='localhost', port=6379, db=0) # Separate connection
queue_key = 'my:blocking:queue:py'

def consumer_py():
    print("Python Consumer started. Waiting for messages...")
    while True:
        # BLPOP blocks until an element is available or timeout (0 for indefinite)
        # Returns a tuple: (key_name, element_value)
        result = r.blpop(queue_key, 0) # Block indefinitely
        if result:
            key, message = result
            print(f"Consumed: \"{message.decode('utf-8')}\" from {key.decode('utf-8')}")
        else:
            print('No messages, timed out (should not happen with timeout 0).')

# consumer_py()
# r.close()
# To run:
# 1. Open one terminal: python producer.py
# 2. Open another terminal: python consumer.py
# You will see the consumer immediately picking up messages as the producer pushes them.
# Hit Ctrl+C in both terminals to stop.

Full Node.js Example with Lists

// full_list_operations.js
const Redis = require('ioredis');
const redis = new Redis();

async function runAllListExamples() {
  console.log('--- Running List Examples ---');

  const timelineKey = 'user:timeline:1001';
  await redis.del(timelineKey); // Clear previous data

  // 1. RPUSH to add new events to the end of a user's timeline
  await redis.rpush(timelineKey, 'Event A: User logged in', 'Event B: Posted photo', 'Event C: Liked post');
  console.log(`\nTimeline after RPUSH: ${await redis.lrange(timelineKey, 0, -1)}`);
  console.log(`Timeline length: ${await redis.llen(timelineKey)}`);

  // 2. LPUSH to add an urgent, latest event to the beginning
  await redis.lpush(timelineKey, 'URGENT: System alert!');
  console.log(`Timeline after LPUSH (urgent event): ${await redis.lrange(timelineKey, 0, -1)}`);

  // 3. LTRIM to keep only the 3 most recent events
  // If we pushed to the left for "latest", then index 0 to 2 are the latest 3
  await redis.ltrim(timelineKey, 0, 2);
  console.log(`Timeline after LTRIM (keeping 3 latest): ${await redis.lrange(timelineKey, 0, -1)}`);
  console.log(`Timeline length: ${await redis.llen(timelineKey)}`);

  // 4. Using a list as a task queue (producer side)
  const taskQueueKey = 'processing:queue';
  await redis.del(taskQueueKey); // Clear previous tasks
  await redis.rpush(taskQueueKey, 'process_image:1', 'send_email:user:5', 'generate_report:Q3');
  console.log(`\nTasks added to queue: ${await redis.lrange(taskQueueKey, 0, -1)}`);

  // 5. Simulating a worker picking up a task (consumer side - single pop)
  let task = await redis.lpop(taskQueueKey);
  if (task) {
    console.log(`\nWorker picked up task: ${task}`);
    console.log(`Remaining tasks: ${await redis.lrange(taskQueueKey, 0, -1)}`);
  }

  // 6. Demonstrate LINSERT before/after another element
  await redis.rpush(timelineKey, 'Event D: Shared post');
  await redis.linsert(timelineKey, 'BEFORE', 'Event D: Shared post', 'Event C: Commented');
  console.log(`\nTimeline after LINSERT: ${await redis.lrange(timelineKey, 0, -1)}`);

  console.log('--- List Examples Complete ---');
  await redis.del(timelineKey, taskQueueKey); // Clean up
  await redis.quit();
}

// runAllListExamples();

Exercises / Mini-Challenges

  1. Recent Products Display:

    • Implement a feature where, for a given userId, Redis stores the last 5 product IDs they viewed.
    • When a user views a product, add its productId to the user:<userId>:viewed_products list.
    • Ensure that the list never exceeds 5 items (i.e., if a 6th item is added, the oldest one is removed).
    • Challenge: If a user views a product they have already viewed recently, make sure it moves to the “most recent” position without creating a duplicate. (Hint: LREM to remove existing, then LPUSH/RPUSH).
  2. Simple Logging Queue:

    • Create a list named application:log:queue.
    • Simulate an application generating log messages by RPUSHing new log entries (e.g., “INFO: User X did Y at timestamp T”).
    • In a separate client/script, simulate a log processor that BLPOPs messages from this queue and prints them. Implement a timeout of 5 seconds for the BLPOP command to show it waiting.
    • Challenge: What happens if the producer stops? How would the consumer behave?
  3. Chat History for a Channel:

    • For a chat channel channel:general:history, store up to 50 messages.
    • When a new message is sent, LPUSH it to the list.
    • After pushing, ensure the list is LTRIMmed to keep only the 50 most recent messages.
    • Retrieve the most recent 10 messages for display.
    • Challenge: How would you fetch messages 11-20 (the “next” page of messages)?

By working through these examples and challenges, you’ll gain a strong grasp of how to effectively use Redis Lists for common patterns like queues, stacks, and limited-size history feeds. In the next chapter, we’ll explore Sets and Sorted Sets, powerful data structures for managing unique collections and ranking data.