Skip to content

Pattern Subscriptions

Verified on Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. Everything below is the output of the @run scripts and the replayed # cli/... transcript. Basics of Pub/Sub (at-most-once, RESP2 vs RESP3 subscribers) are in Pub/Sub.

PSUBSCRIBE pattern [pattern ...] is SUBSCRIBE with glob patterns instead of literal channel names, so one subscription can follow a whole family of channels, including ones that do not exist yet.

PatternMeaning
*any sequence of characters, including none
?exactly one character
[abc], [a-c]one character from the set or range
[^x]one character that is not x
\*a literal *

I subscribed one connection to news.*, h?llo, log.[ew]*, raw.[^x] and published to eleven channels. Each row is the number of deliveries PUBLISH reported:

Channel publishedDeliveriesWhy
news.sports1matches news.*
news.1* also matches the empty string
news.a.b1. is an ordinary character, * spans it
newsflash0no literal news. prefix
hello, hallo1, 1? is any single character
heello0? is exactly one
log.error1[ew] = e or w
log.info0i is not in the set
raw.a, raw.x1, 0[^x] excludes x

A message that arrived through a pattern is not a message but a pmessage: ["pmessage", pattern, channel, payload]. In ioredis the two events have different handler signatures, and the same publish can fire both: I also subscribed to the literal channel news.tech and published there, which matches both the literal subscription and news.*. PUBLISH returned 2, and the client saw two events:

src/pubsub-streams-pubsub-patterns-pmessage.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const sub = new Redis(url);
const pub = new Redis(url);
const seen: string[] = [];
// 'message' has (channel, message); 'pmessage' has FOUR arguments: (pattern, channel, message)
sub.on('message', (channel, message) => seen.push(`message ${channel} ${message}`));
sub.on('pmessage', (pattern, channel, message) => seen.push(`pmessage ${pattern} ${channel} ${message}`));
console.log(await sub.psubscribe('news.*', 'h?llo', 'log.[ew]*', 'raw.[^x]')); // 4 patterns -> count 4
console.log(await sub.subscribe('news.tech')); // + 1 literal channel -> count 5
// which channels does each pattern catch? PUBLISH returns the number of deliveries
for (const ch of ['news.sports', 'news.', 'news.a.b', 'newsflash', 'hello', 'hallo', 'heello', 'log.error', 'log.info', 'raw.a', 'raw.x']) {
console.log(ch.padEnd(12), await pub.publish(ch, 'x'));
}
// news.tech matches BOTH the pattern news.* and the literal subscription: two deliveries, two events
console.log('news.tech'.padEnd(12), await pub.publish('news.tech', 'both'));
await new Promise((r) => setTimeout(r, 50));
console.log(seen.filter((s) => s.includes('news.tech'))); // [ 'message news.tech both', 'pmessage news.* news.tech both' ]
// a match that came only through a pattern fires 'pmessage' and NOT 'message'
console.log(seen.filter((s) => s.includes('news.sports'))); // [ 'pmessage news.* news.sports x' ]
sub.disconnect();
pub.disconnect();

Real output (this Mac):

4
5
news.sports 1
news. 1
news.a.b 1
newsflash 0
hello 1
hallo 1
heello 0
log.error 1
log.info 0
raw.a 1
raw.x 0
news.tech 2
[ 'message news.tech both', 'pmessage news.* news.tech both' ]
[ 'pmessage news.* news.sports x' ]

Two more facts from those numbers. The value returned by psubscribe/subscribe (4, then 5) is the total number of subscriptions on the connection, channels and patterns together. And a match that came only through a pattern fires pmessage and not message; a client that only listens for message sees nothing.

PUBLISH news.sports "score update"
PUBLISH news.tech "new release"
PUBLISH weather.today "sunny"
PUBSUB NUMPAT

PUNSUBSCRIBE [pattern ...] removes pattern subscriptions, replying once per pattern with ["punsubscribe", pattern, remaining]. remaining counts all subscriptions left on the connection (channels and patterns), as the 4/5 above show, and the connection leaves subscribe mode when it reaches 0. With no arguments it removes every pattern.

Every PUBLISH must be matched against every active pattern, whereas literal channels are a hash lookup. I measured the server-side average of 20,000 pipelined PUBLISH orders:1 x, from INFO commandstats, with subscribers whose patterns never match:

src/pubsub-streams-pubsub-patterns-cost.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const pub = new Redis(url);
// average server time of PUBLISH over 20,000 publishes, from INFO commandstats
async function usPerPublish(label: string) {
await pub.config('RESETSTAT');
const batch = pub.pipeline();
for (let i = 0; i < 20000; i++) batch.publish('orders:1', 'x');
await batch.exec();
const line = (await pub.info('commandstats')).split('\r\n').find((l) => l.startsWith('cmdstat_publish'));
console.log(label.padEnd(24), line?.match(/usec_per_call=([\d.]+)/)?.[1], 'us per PUBLISH');
}
await usPerPublish('no subscriptions');
for (const n of [100, 10000]) {
const sub = new Redis(url);
await sub.psubscribe(...Array.from({ length: n }, (_, i) => `p${i}:*`)); // none of them match orders:1
await usPerPublish(`${n} patterns`);
sub.disconnect();
await new Promise((r) => setTimeout(r, 300));
}
const literal = new Redis(url);
await literal.subscribe(...Array.from({ length: 10000 }, (_, i) => `c${i}`));
await usPerPublish('10000 literal channels');
literal.disconnect();
pub.disconnect();

Real output (this Mac):

no subscriptions 0.04 us per PUBLISH
100 patterns 0.77 us per PUBLISH
10000 patterns 115.57 us per PUBLISH
10000 literal channels 0.09 us per PUBLISH

So 100 patterns cost about 20x the baseline (still under a microsecond), 10,000 patterns cost about 2,900x, roughly 0.1 ms of command thread for every publish, for every publisher; 10,000 literal channels cost nothing measurable. The cost is per pattern and paid on the publish path. Keep patterns few and narrow, and use literal channels when the names are known.

Redis publishes internal events on built-in channels, which is what pattern subscriptions are most often used for: __keyevent@0__:expired (a key in db 0 expired) and __keyspace@0__:mykey (something happened to mykey). They are off by default. CONFIG GET reports the flags in a canonical order, so KEA comes back as AKE:

cli/pubsub-streams-pubsub-patterns-1.redis
127.0.0.1:6379> CONFIG SET notify-keyspace-events KEA
OK
127.0.0.1:6379> CONFIG GET notify-keyspace-events
1) "notify-keyspace-events"
2) "AKE"
127.0.0.1:6379> CONFIG SET notify-keyspace-events ""
OK

The full lesson, with a timed expired event, is Keyspace notifications.

An assistant writes a subscriber for all alert channels:

🐛 Spot the bug

const sub = new Redis(process.env.REDIS_URL!);
await sub.psubscribe('alerts.*');
sub.on('message', (channel, message) => {
notifyOncall(channel, message); // never runs
});
Show the answer

The subscription succeeds and PUBLISH alerts.billing ... reports 1 delivery, but the handler is attached to message; a pattern subscription emits pmessage with an extra first argument, so nothing is ever notified. In our run the pattern-only match produced [ 'pmessage news.* news.sports x' ] and no message event at all. The fix is the right event and signature:

sub.on('pmessage', (pattern, channel, message) => {
notifyOncall(channel, message);
});

A pattern subscription is a filter the server evaluates for every message. It buys you not having to know channel names in advance, and you pay for it in CPU on every publish, on the one command thread. Treat patterns as a scarce resource, and treat pmessage as its own event with its own arguments.

ChoiceBenefitCost
PSUBSCRIBE with a globOne subscription for a whole family, including future channelsMatched against every publish: 0.04 us to 115 us per publish going from 0 to 10,000 patterns
Literal SUBSCRIBE channelsHash lookup, flat cost even at 10,000 channels (0.09 us)You must list the names
Keyspace notifications over Pub/SubReal-time events with no pollingExtra work on commands that touch keys, still fire-and-forget
Also subscribing to a literal that a pattern coversSometimes deliberateThe same message is delivered twice (PUBLISH returned 2)
  • “A pattern match arrives as a message.” It arrives as pmessage with four parts.
  • “The number returned by PSUBSCRIBE counts patterns.” It counts every subscription on the connection, channels included.
  • “Patterns are free.” They are linear in the number of active patterns: 115 us per publish at 10,000.
  • “* requires at least one character.” news.* matched news..
  • Listening for message on a pattern subscription — attach pmessage (see the exercise).
  • Subscribing to * or thousands of patterns — every publish pays for every pattern.
  • Enabling KEA (everything) on a busy production server — enable only the event classes you consume.

Real-world example

Redis’s own documentation describes glob-style pattern subscriptions, the pmessage format and the subscription-count semantics in Redis Pub/Sub; the pattern rules are the ones of the KEYS glob (PSUBSCRIBE).

Which glob wildcard matches exactly one character in a PSUBSCRIBE pattern?
How many parts does a pmessage push contain?
What is the second element in a pmessage push?
A connection is subscribed to the pattern news.* and to the channel news.tech. PUBLISH news.tech x returns...
You PSUBSCRIBE 10,000 patterns that never match. What happens to the cost of every PUBLISH?
psubscribe('a.*') then subscribe('b') on one ioredis connection: what does the second call resolve to?