Native compressed bitmaps in Redis core:
the design, the review, and what it unlocks.
Antonio Viggiano · redisroaring.com
October 8, 2026
127.0.0.1:6379> SETBIT clicks 4000000000 1 (integer) 0 127.0.0.1:6379> MEMORY USAGE clicks (integer) 536870944 # 512 MiB
127.0.0.1:6379> SETBIT clicks 4000000000 1 (integer) 0 127.0.0.1:6379> MEMORY USAGE clicks (integer) 313 # 313 bytes
Same command, same reply.
Security Researcher, redis-roaring maintainer, Database engineer.
Why string bitmaps are expensive for sparse data, and how Roaring bitmaps fix it.
Type vs encoding, determinism, replication, persistence, and what changed during review.
Memory savings and the BelugaDB workload.
A closer look at key conversion.
Testing, fuzzing, bugs found, and benchmark results.
~45 minutes of content · 15 minutes Q&A
Fuzzing, redis-roaring, BelugaDB.
RevMob, Latin America's largest mobile ad network
{"country":"Brazil","gender":"male","action":"click"}
{"country":"United States","gender":"female","action":"impression"}
{"country":"Brazil","gender":"female","action":"click"}
{"country":"United States","gender":"male","action":"click"}
{"country":"United States","gender":"female","action":"install"}
{"country":"United States","gender":"female","action":"impression"}
{"country":"Israel","gender":"male","action":"impression"}
{"country":"United States","gender":"female","action":"install"}
Each column value becomes a bitmap over row IDs; the WHERE clause is boolean logic (AND / OR / NOT) over those sets.
| row | country | gender | action |
|---|---|---|---|
| 0 | Brazil | male | click |
| 1 | United States | female | impression |
| 2 | Brazil | female | click |
| 3 | United States | male | click |
| 4 | United States | female | install |
| 5 | United States | female | impression |
| 6 | Israel | male | impression |
| 7 | United States | female | install |
SELECT COUNT(*) FROM events WHERE country = 'Brazil' AND action = 'click' → 2 # rows 0 and 2
Excellent compression for sparse and clustered data; set algebra without decompressing.
No random access: reading bit N means scanning from the start. More data means more scanning.
Compression through the existing bitmap API, with Redis handling persistence. No separate R.* commands, and existing workloads get cheaper.
What bitmaps are used for, and why Redis needs compressed bitmaps.
127.0.0.1:6379> SETBIT b 7 1 (integer) 0 127.0.0.1:6379> TYPE b string 127.0.0.1:6379> GET b "\x01" # it really is just bytes
Roaring bitmaps are the standard structure behind inverted indexes and segmentation engines.
The decisions that shaped the PR.
Zero breaking changes means workloads that stay on the bitmap commands adopt compression with a config flip and no code changes. It also gives redis-roaring module users a portability path into core, with one command surface everywhere.
127.0.0.1:6379> TYPE clicks bitmap 127.0.0.1:6379> OBJECT ENCODING clicks "bitmap-roaring" 127.0.0.1:6379> GET clicks (error) WRONGTYPE Operation against a key holding the wrong kind of value 127.0.0.1:6379> BITCOUNT clicks (integer) 1 # bitmap commands work unchanged
Cannot serve GET / APPEND without expanding the bitmap.
WRONGTYPE makes the boundary explicit.
Too restrictive for some redis-roaring users.
roaring64 inside, the same public limits as string bitmaps.
light panels: designs we considered and moved away from
| Path | When | Notes |
|---|---|---|
| SETBIT / BITFIELD write | config on | new keys created Roaring; existing string bitmaps convert in place, preserving TTL & module metadata |
| BITOP | any config | ≥ 1 Roaring source → Roaring destination; with config on, all-string sources also produce Roaring |
| RESTORE / MIGRATE | payload is Roaring | DUMP payloads carry the representation |
| replication / AOF / RDB | master decides | replicas never re-derive the decision (next slide) |
CONFIG SET bitmap-default-roaring yes
With the config off, bitmap writes keep creating plain strings. The default applies on the primary, when the write can propagate to both AOF and replicas.
Roaring keys are never demoted back to strings. The type boundary is strict and one-way in v1.
Nondeterminism is acceptable for invisible encoding decisions, never for WRONGTYPE-visible type decisions.
# SETBIT that creates or converts a key MULTI BITCONVERT clicks SETBIT clicks 65536 1 EXEC # BITOP: config selects a Roaring result BITROAROP OR dest src1 src2
Ship the converted value as a blob. Deterministic, but preserving metadata needed a specialized option.
Replay BITOP, then convert its result. But replicas briefly saw a string and emitted different notifications.
Store the native result directly: same notifications and callbacks. Public BITOP syntax stays unchanged.
light panels: designs we considered and moved away from
Agreed replication design merges: BITOP + BITCONVERT, duplicate command deleted.
An automated review-fix PR for unrelated bot findings silently re-adds the rejected command. CI stays green.
sundb's review: "Why this command come back again?"
Reverted byte-identical to the agreed design; replay tests added.
New notification tests justify a deliberate return: BITROAROP.
Coding agents helped ship many tests, but also reintroduced rejected designs that only design-level review caught.
Saving raw bytes materializes the full logical range, and on replicated paths the "length ≤ proto-max" assumption does not hold. One set bit at offset 2⁴⁰ means ~128 GiB materialized at save time.
A compressed type must stay compact on every path: command replies, replication, RDB, DUMP. And a serialization format is a commitment to external tooling, not just to Redis.
/* BITOP NOT fills every missing 2^16-bit Roaring * chunk in the logical range. Bound that allocation * amplification rather than the source's byte length: * dense sources above 512 MiB can remain cheap to * complement, while a sparse source cannot make one * command materialize an unbounded number of chunks. * This is a floor: the effective limit grows with * proto-max-bulk-len to cover client-created values. */ #define BITROAR_BITOP_NOT_MAX_MISSING_CHUNKS (1ULL << 16)
A flat 512 MB cap was rejected, since strings can legitimately hold that. The 65,536-chunk floor grows with proto-max-bulk-len, including BITFIELD edge cases. The check costs O(resident containers).
Dense materialization of a Roaring key is confined to a debug path (DEBUG BITMAP-RAW), and even that respects proto-max-bulk-len. A sparse bitmap cannot produce a huge reply through a normal command.
Internals are 64-bit (roaring64), but the exposed offset range stays exactly what string bitmaps allow. Raising the limit is a separate, deliberate decision.
Guiding rule: ship the smallest reviewable change that delivers the value. Every extra knob multiplies the review and test matrix.
| Cut | Why |
|---|---|
| BITMAP CONVERT (public) | covered by the config; replication uses the internal BITCONVERT primitive |
| conversion thresholds / heuristics | threshold decisions depend on allocator state and cannot be replayed deterministically |
| new roaring-only commands future proposals | BITSETARRAY: bulk integer load BITGETBITS: batch membership BITSETRANGE: fill a range |
| 64-bit offsets at the surface | internals ready; raising the limit is its own compatibility discussion |
| demotion (roaring → string) | a one-way boundary keeps the model explainable; revisit with evidence |
Memory savings and the BelugaDB workload.
# The ad-tech dataset from the start of the talk, # one bitmap per value: country:brazil {0 2} country:united-states {1 3 4 5 7} country:israel {6} gender:male {0 3 6} gender:female {1 2 4 5 7} metric:clicks {0 2 3} metric:impressions {1 5 6} metric:installs {4 7} universe {0 1 2 3 4 5 6 7}
BITOP mixes string and Roaring operands freely. All 8 operators:
AND · OR · XOR · NOT · DIFF · DIFF1 · ANDOR · ONE
# Extend the length beyond the universe SETBIT metric:installs 8 0 BITOP NOT raw metric:installs # a NOT must be bounded by the universe BITOP AND not-installs universe raw BITOP AND result gender:female metric:clicks not-installs # result = {2}: the Brazilian female clicker
Complement queries ("users NOT from the US") need a bitmap of all valid rows to bound the NOT. Building that universe efficiently is what motivated the SETFULL discussion; range fills are a proposed extension.
127.0.0.1:6379> CONFIG SET notify-keyspace-events Ebc OK # b = bitmap events, c = type_changed 127.0.0.1:6379> SUBSCRIBE __keyevent@0__:type_changed # fires when a string converts to a Roaring bitmap 127.0.0.1:6379> DEBUG BITMAP-RAW clicks "\x00\x00..." # bounded by proto-max-bulk-len
A closer look at key conversion.
if (transition) { /* 1. Queue conversion before the write */ bitroarPropagateConvert(c, c->argv[1]); /* 2. Convert and notify listeners */ bitroarConvertKey(c, c->argv[1], o, link); /* 3. Look up the key again */ link = NULL; o = lookupKeyWriteWithFlags(c->db, c->argv[1], LOOKUP_NOTOUCH); ... }
Replicas must convert the key before replaying the write.
Code listening for the change can replace or delete the key.
Continue from the current value. The old reference may no longer be valid.
Testing, fuzzing, the bugs found along the way, and benchmark results.
The string implementation is the specification. The oracle suite runs every scenario in both modes; only TYPE may differ.
# hostile payload, any sanitize setting 127.0.0.1:6379> RESTORE k 0 "\x21...corrupted..." (error) ERR Bad data format # the payload is rejected, not parsed
A review-requested restructure moved operand cleanup after the keyspace store; BITOP AND k k on an empty Roaring key freed an operand still in use. Caught the same day; regression test added.
Conversion once serialized the value after the notification path invalidated the pointer, calling createDumpPayload(NULL). Rule extracted: dump before you notify. Caught by the local test battery before any push.
Bitset relocation computed an offset from freed storage; fixed offset-first, verified under ASan/UBSan. And CRoaring's ART node table leaves slot 0 uninitialized by design, so defrag walks are bounded to typecodes 1–5; otherwise the defrag allocator receives uninitialized pointers.
Review of the vendored library found the non-atomic refcount decrement comparing the pointer instead of the value, so the last reference was never released. Fixed upstream in CRoaring #859 (Aug 2026). Redis itself was unaffected.
Geomean across all workloads (vs string): point/count/scan 0.455× · algebra 0.305× the latency.
vs the module: ≈ parity on commands, and 21× faster AOF rewrite. Moving from the module to core does not cost performance.
bars = speedup factor, core Roaring vs string · three-way benchmark posted on the PR, July 2026 · real dataset, 5 samples/workload
| Workload | vs string | Context |
|---|---|---|
| mixed string ↔ roaring BITOP | 30–40× slower | the one that matters: conversion runs inside the measured path; disappears once keys are converted, and a batch-conversion path is a natural next step |
| DUMP/RESTORE sparse native | ≈26% perf | one-off load and migration operations; known serialization headroom |
| sparse key built via SETBIT | ≈16% perf | one-off ingest; the proposed bulk-load command removes it |
| dense all-native BITOP AND | 23 vs 7 µs | µs-scale and near run variance; not the workload compression targets |
| dense data memory | +0.6% | parity by design; compression cannot help dense data |
July 2026 measurements. Mixed-input conversion has since changed; current-head ratios need a new benchmark.
redis/redis#15296: proposal issue opened
#15331 opens as draft
Ready for review · three-way benchmark posted
Persistence format settles · CRoaring fix lands upstream
329 / 336 threads resolved · conflict-free · coverage check running · targeting Redis 8.12
Reviews & design: sundb, LiorKogan, moticless, oranagra.
Coordination & planning: yaacov.hazan, eyal.boumgarten, bosmat.tuvel, pieter.
No new public commands. Flip bitmap-default-roaring and a sparse key drops from 512 MiB to 313 bytes, measured.
Types are visible, so the master decides once. BITCONVERT and BITROAROP carry that decision to replicas.
17 tracked decisions, several reversed during review. Replication evolved again to preserve notifications. The result is simpler than the first draft.
redis/redis #15331 · redisroaring.com · github.com/aviggiano
Antonio Viggiano · Roaring Bitmaps in Redis