Redis · Lists

Lists

A list is an ordered sequence of strings under one key. In applications it is almost always a job queue or a log of recent events. Below: what the structure is, where it belongs, and every method with its arguments and examples.

What a list is

A Redis list is a doubly linked list of strings, not an array. Everything else follows from that:

  • Adding and taking from either end is O(1), whatever the length. A million elements do not make push slower.
  • Access by index is O(N): to reach the middle Redis walks the elements. at(500000) really does step through half a million nodes.
  • Order comes from insertion, not from the values. A list is never sorted and allows duplicates.
  • Length goes up to 4,294,967,295 elements.

A key exists exactly as long as it holds at least one element: taking the last one deletes the key too. The reverse holds as well — a push into a missing key creates it. There is no separate “create a list” step, and nothing to call for it.

Elements are always strings

A list stores bytes. Arrays and objects reach it only if the config sets a serializer; otherwise an array turns into the string "Array" and only a PHP warning says so.

Where lists belong

A job queue. The common case. A producer appends to the tail, a consumer takes from the head — first in, first out. Redis is single-threaded, so taking is atomic: two consumers never receive the same job.

A log of the last N events. A list plus a length cap: a user’s audit trail, recent errors, an activity feed. Old entries fall off by themselves.

A buffer between something fast and something slow. A request handler drops a record in, a background worker drains it in batches. A spike does not knock the slow half over, because the list accepts faster than the worker consumes.

A stack. Add and take at the same end — LIFO. Rarer than the queue.

And where a list is the wrong choice:

The task Why not a list What instead
Check whether an element is present O(N) over the whole length a set (SET)
Keep values unique a list allows duplicates a set
Keep things ordered by value order comes from insertion only a sorted set (ZSET)
Several consumers, each getting every message an element goes to one of them a stream or pub/sub
Keep history and replay it a taken element is gone a stream (XADD)
Read from the middle often O(N) on every access a hash or another structure

The handle

php
$jobs = $store->list('jobs');    // the server sees 'queue:jobs'

list() executes nothing — it is a view, not a request. The prefix is applied here, once, so there is nowhere left to forget it. The handle holds no connection either: every call asks the store for the client — except consume(), which has its own, see below.

A handle can be created per call, but for a consumer loop keep it: then the blocking reads reuse a single connection.


Method reference

Adding

push()

php
public function push(mixed $value, ?int $cap = null): int

Appends an element to the tail — the end pop() does not take from, which is what makes push() + pop() a queue.

Argument Type Default What it does
$value mixed what to store. A string or a number; anything else needs a serializer
$cap ?int null the maximum length of the list. null means no limit

Returns the length after the append. With $cap, the length after trimming — never more than $cap.

php
$jobs->push(json_encode(['type' => 'email', 'to' => $user->email]));   // 1
$jobs->push(json_encode(['type' => 'sms']));                           // 2

With $cap the list trims itself — the “last N events” pattern:

php
foreach ($lines as $line) {
  $audit->push($line, cap: 1000);      // only the newest thousand is kept
}

$audit->count();   // 1000, however many lines arrived

The cap runs as one transaction (MULTI: append, trim), so the list is never for a moment longer than $cap — and cannot stay longer if something fails between the two commands.

pushFront()

php
public function pushFront(mixed $value, ?int $cap = null): int

The same, but at the head — where pop() takes from. Two uses: building a stack, and putting a job back at the front of the queue.

Argument Type Default What it does
$value mixed what to store
$cap ?int null maximum length; the newest are kept, i.e. the first $cap

Returns the length after the push (and the trim).

php
// a stack: add and take at the same end
$undo->pushFront($action);
$undo->pop();            // the last action

// an urgent job jumps the queue
$jobs->pushFront($urgentJob);

Taking

pop()

php
public function pop(): mixed

Takes an element from the head and removes it from the list. No arguments.

Returns the element, or null when the list is empty or absent. The driver reports false there; null is chosen so that “empty” cannot be confused with a stored false.

php
while (($job = $jobs->pop()) !== null) {
  $this->handle($job);      // drain what has piled up, then leave
}

The operation is atomic: however many workers call pop() at once, each element goes to exactly one of them.

A taken element exists only in the worker's memory

If the worker dies between pop() and the end of the work, the job is gone with nothing to show for it. When that is unacceptable, move the element instead of taking it: moveTo().

popBack()

php
public function popBack(): mixed

The same, from the tail. No arguments. Returns the element or null.

php
$jobs->push('a');
$jobs->push('b');

$jobs->popBack();    // 'b' — the last one added
$jobs->pop();        // 'a' — the first one added

Waiting

consume()

php
public function consume(float $timeout = 0.0): mixed

Waits for an element and takes it from the head. If one is already there, it returns immediately.

Argument Type Default What it does
$timeout float 0.0 seconds to wait. 0 waits indefinitely

Returns the element, or null if the time ran out with the list still empty.

main/Daemons/JobWorker.php
$jobs = $store->list('jobs');

while (!$this->stopping) {
  $job = $jobs->consume(timeout: 5);

  if ($job !== null) {
      $this->handle($job);
  }
  // null just means the wait expired; check the stop flag and wait again
}

$jobs->close();

The timeout is not there to avoid “waiting too long” but to hand control back to the loop periodically — otherwise the daemon would never notice a stop signal.

consume() does not take a pooled connection

A blocking command occupies its connection for the whole wait. Ten consumers with timeout: 30 against a pool of ten would hold the entire pool for half a minute, and every other request would fail with “no free connection” — while Redis itself sits idle, so the incident reads as “Redis is slow”.

The handle therefore opens a connection of its own on the first consume() and reuses it afterwards. Which is why the handle is worth keeping for the whole loop: a new handle means a new connection.

The read timeout is raised automatically

A connection is configured to raise read error on connection when the server stays silent longer than readTimeout (two seconds by default), and a blocking read is precisely “the server is deliberately silent”. So consume() raises the read timeout to cover the wait. Without that, consume(timeout: 5) would die on the second second — and not as a timeout, but as a connection error.

close()

php
public function close(): void

Closes the connection consume() opened. No arguments, returns nothing. Always safe to call: if consume() was never used, it does nothing.

Dropping the last reference to the handle closes the connection too, but a long-running daemon should not rely on that.

Moving

moveTo()

php
public function moveTo(self $target): mixed

Atomically moves an element from the head of this list to the tail of another — taking from where pop() takes and putting where push() puts.

Argument Type Default What it does
$target RedisList where to move it. Must belong to the same config

Returns the element moved, or null when the source list is empty. Throws LogicException when the two lists belong to different configs.

This is the basis of a queue that does not lose work when a worker dies: the element is at every moment in exactly one of the two lists.

php
$jobs       = $store->list('jobs');
$processing = $store->list('jobs:processing');

$job = $jobs->moveTo($processing);

if ($job !== null) {
  $this->handle($job);
  $processing->remove($job);      // acknowledge: done
}

What happens to jobs left in processing by a crashed worker — return them to jobs, raise an alert, bury them as dead — is the application’s decision. The package gives the tool and imposes no policy: only the author of a job knows what retrying it costs.

Within one config only

LMOVE runs on a single connection, so both keys must live on the same endpoint. Moving into a list of another config is refused with an exception — without that check the element would be written into our database under the other name, and everything would look like it worked.

A special case is moving a list into itself: the element travels from head to tail, so the list rotates. That is how you walk a queue in a circle without losing anything:

php
$workers->moveTo($workers);    // 'a','b','c' → 'b','c','a'

Reading

Nothing in this section takes anything out.

count()

php
public function count(): int

The length of the list. No arguments. Returns the number of elements, 0 for a missing key. It is O(1): Redis stores the length rather than counting it.

php
if ($jobs->count() > 10_000) {
  $this->alert('the queue is growing faster than it is drained');
}

range()

php
public function range(int $start = 0, int $stop = -1): array

Returns a slice of the list, changing nothing.

Argument Type Default What it does
$start int 0 index of the first element of the slice
$stop int -1 index of the last element, inclusive

Both indexes may be negative, counting from the tail: -1 is the last, -2 the one before it. The defaults 0, -1 mean “the whole list”. Bounds beyond the length are not an error: the slice simply comes back shorter, or empty.

Returns an array of elements; for a missing key, an empty array.

php
$events->range();          // everything
$events->range(0, 9);      // the first ten
$events->range(-5, -1);    // the last five
$events->range(100, 200);  // [] — when there are fewer elements

stop is included in the result

This differs from array_slice(): range(0, 9) returns ten elements, not nine. That is how the LRANGE command itself works.

at()

php
public function at(int $index): mixed

An element by index, without taking it out.

Argument Type Default What it does
$index int the position; a negative one counts from the tail

Returns the element, or null when there is no such index or no such key.

php
$jobs->at(0);     // the next job, without claiming it
$jobs->at(-1);    // the last one added
$jobs->at(999);   // null

Remember the O(N): cheap at the ends, expensive in the middle of a long list.

Changing

remove()

php
public function remove(mixed $value, int $count = 1): int

Removes elements equal to the value given. The comparison is byte-for-byte, as the server sees them.

Argument Type Default What it does
$value mixed what to remove
$count int 1 how many occurrences: > 0 from the head, < 0 from the tail, 0 all of them

Returns how many elements were actually removed.

php
// list: a, b, a, c, a
$list->remove('a');            // 1 → b, a, c, a   (the first from the head)
$list->remove('a', -1);        // 1 → b, a, c      (the last from the tail)
$list->remove('a', 0);         // 1 → b, c         (every one left)

Its main use is acknowledging work, paired with moveTo():

php
$processing->remove($job);     // exactly one copy of this job

trim()

php
public function trim(int $start, int $stop): bool

Keeps only the given range and deletes everything else. Indexes work as in range() — inclusive, negatives allowed.

Argument Type Default What it does
$start int the first index to keep
$stop int the last index to keep, inclusive

Returns true when the command ran.

php
$events->trim(0, 999);      // keep the first thousand
$events->trim(-100, -1);    // keep the last hundred

A range beyond the length empties the list

trim(5, 10) on a two-element list removes both, and the key stops existing — Redis keeps no empty containers. That is not an error and nothing reports it, so the bounds are worth calculating rather than guessing.

For “the last N” a separate trim() is usually unnecessary — push($value, cap: N) does the same thing atomically.

set()

php
public function set(int $index, mixed $value): bool

Overwrites the element at a position. The length does not change.

Argument Type Default What it does
$index int the position; a negative one counts from the tail
$value mixed the new value

Returns true on success and false when the index does not exist. There is deliberately no exception: the list is authoritative about its own length, so being out of range is an answer, not a fault.

php
$jobs->set(0, $patchedJob);
$jobs->set(999, $x);           // false, the list is unchanged

The key as a whole

Elements of a list have no lifetimes of their own — unlike hash fields. The lifetime belongs to the key.

expireKey()

php
public function expireKey(int $seconds): bool
Argument Type Default What it does
$seconds int after how many seconds to delete the whole key

Returns true when the lifetime was set, false when there is no such key.

php
$draft->push($chunk);
$draft->expireKey(3600);     // the draft lives an hour

The lifetime counts from now, not up to a moment: expireKey(time() + 3600) asks for fifty-six years and says nothing about it.

keyTtl()

php
public function keyTtl(): ?int

No arguments. Returns the remaining seconds, or null — both when no lifetime is set and when there is no key.

deleteKey()

php
public function deleteKey(): bool

Deletes the list outright. No arguments. Returns true when the key existed.

Utility

name()

php
public function name(): string

The key name with the prefix — what the server sees. Needed when reaching the list through raw():

php
$store->raw()->lPos($jobs->name(), $needle);     // a command the handle does not wrap

configClass()

php
public function configClass(): string

The config class the list lives on. moveTo() uses it to refuse moving an element between different endpoints.

Mapping to Redis commands

Method names describe the intent; here is what goes to the server.

Method Command
push(value) / push(value, cap) RPUSH / MULTI(RPUSH, LTRIM)
pushFront(value) / pushFront(value, cap) LPUSH / MULTI(LPUSH, LTRIM)
pop() / popBack() LPOP / RPOP
consume(timeout) BLPOP
moveTo(list) LMOVE
count() / range() / at() LLEN / LRANGE / LINDEX
remove() / trim() / set() LREM / LTRIM / LSET
expireKey() / keyTtl() / deleteKey() EXPIRE / TTL / DEL

Commands that are not here — LPOS, LINSERT, RPOPLPUSH, BLMPOP, LMPOP — are reachable through raw(), remembering name().

Next

  • Hashes — fields and their own lifetimes
  • Stores — strings, counters, raw() and transactions
  • Streams — when history and delivery tracking are needed
  • Connection pool — why consume() stays away from the pool