A Redis bitmap is a string interpreted as individual bits, not a separate key type. It is useful for dense yes/no membership such as daily activity. Ten million contiguous flags need 1,250,000 payload bytes, but that estimate excludes keys, object metadata, allocation overhead, and any mapping from business identifiers to bit positions.
Define the offset contract
SETBIT writes zero or one at a zero-based offset and returns the previous bit. Setting an already-set bit again does not increase membership. Offset zero is the most significant bit of the first byte; offset eight starts the second byte. Reading the same key with GET returns its underlying bytes, which need not be valid text.
GETBIT returns zero for a missing key or an offset beyond the current string without expanding it. Zero therefore does not distinguish an explicitly inactive user from an unrecorded one. Decide whether those states may share a representation. SETBIT and GETBIT require a string value and reject a hash rather than converting its fields into bits; BITOP instead writes a new string at its destination.
Memory follows the highest written offset, not the number of ones. Writing offset n requires at least floor(n / 8) + 1 payload bytes, even when the requested value is zero. Clearing that bit later does not shorten the string. Sparse external identifiers can therefore create a huge allocation for almost no membership. Validate the offset range and use a stable, suitably dense mapping.
Count and combine deliberately
BITCOUNT scans all bytes by default. Its optional start and end arguments are inclusive byte positions unless BIT is specified; that modifier is available from Redis 7.0. Changing units changes the selected interval, not the representation stored at the key. The command returns a population count, not the highest occupied identifier.
BITOP OR combines membership, while AND computes intersection. Shorter inputs and missing keys behave as zero-padded inputs up to the longest input length. The destination is overwritten with a new string; an existing destination expiration is removed. Derived results need their own deliberate lifetime policy, and recomputing them does not preserve a previously attached deadline.
When it breaks
BITCOUNT, BITPOS, and BITOP can scan substantial data. Optimized bit operations do not make full-bitmap work constant-time, and extending a string must allocate the intervening space. Evaluate offset span, query frequency, result lifetimes, and peak allocation together. A tiny number of active users says little about the resource cost of a sparse bitmap.
BITPOS returns an absolute bit offset even when searching a subrange. Searching for zero without an explicit end can return the first position beyond an all-one string, treating the right side as zero-padded. With both bounds specified, no matching bit in that interval returns -1. That difference matters when the bitmap represents a fixed-capacity resource pool.
Finding a zero is not reserving it. Two callers can read the same free position before either changes it. A separate BITPOS and SETBIT sequence is not one atomic claim, and ignoring SETBIT’s old-value result can double-assign a slot. Likewise, OR-ing two day keys is only meaningful when both use the same identifier mapping.
Check it quickly
Use a POSIX environment with a C compiler, make, curl, tar, and Python 3.10 or newer. This ran on macOS with Python 3.14.4, Redis 8.10.1, and redis-py 8.1.0. Build only Redis core with libc and install the client in a temporary virtual environment; no optional modules or system-wide installation are required.
redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0
Run the Python block with "$redis_lab/venv/bin/python" in the same shell. It uses its own Redis Unix socket under /tmp, disables TCP and persistence, and removes only its runtime directory after stopping its process. Downloading dependencies requires network access. The largest bitmap in this experiment has one MiB of payload; it does not allocate the enormous example offsets discussed above.
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry
@contextmanager
def instance():
with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
endpoint = str(Path(directory) / "redis.sock")
process = subprocess.Popen([
os.environ["REDIS_SERVER"], "--port", "0",
"--unixsocket", endpoint, "--unixsocketperm", "700",
"--save", "", "--appendonly", "no", "--dir", directory,
"--maxmemory-policy", "noeviction",
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
socket_timeout=2, retry=Retry(NoBackoff(), 0))
try:
deadline = time.monotonic() + 5
while True:
assert process.poll() is None, "Redis exited during startup"
try:
if client.ping():
break
except redis.ConnectionError:
if time.monotonic() >= deadline:
raise
time.sleep(0.02)
yield client
finally:
client.close()
if process.poll() is None:
process.terminate()
try:
_, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise
assert process.returncode == 0, errors.decode()
with instance() as client:
assert client.getbit("missing", 100) == 0
assert client.exists("missing") == 0
assert client.setbit("active", 0, 1) == 0
assert client.setbit("active", 2, 1) == 0
assert client.setbit("active", 2, 1) == 1
assert client.type("active") == b"string"
assert client.get("active") == b"\xa0"
assert client.bitcount("active") == 2
print("offset zero is the high bit; repeated SETBIT returns old 1 without adding membership")
highest = 8 * 1024 * 1024 - 1
assert client.setbit("sparse", highest, 0) == 0
assert client.strlen("sparse") == 1024 * 1024
assert client.bitcount("sparse") == 0
assert client.setbit("sparse", highest, 1) == 0
assert client.setbit("sparse", highest, 0) == 1
assert client.strlen("sparse") == 1024 * 1024
print("writing a distant zero allocates 1048576 payload bytes; clearing does not shrink")
client.set("range", b"\x80\x01")
assert client.bitcount("range", 0, 1) == 2
assert client.bitcount("range", 0, 1, mode="BIT") == 1
assert client.bitpos("range", 1, 1, 1) == 15
client.set("full", b"\xff")
assert client.bitpos("full", 0) == 8
assert client.bitpos("full", 0, 0, 0) == -1
print("ranges default to bytes; BIT changes units; BITPOS returns absolute bit offsets")
client.set("other", b"\x50\x01")
client.set("union", b"obsolete", px=60000)
assert client.bitop("OR", "union", "active", "other") == 2
assert client.get("union") == b"\xf0\x01"
assert client.bitcount("union") == 5
assert client.ttl("union") == -1
assert client.bitop("AND", "intersection", "active", "other") == 2
assert client.get("intersection") == b"\x00\x00"
print("BITOP zero-pads shorter inputs; OR has 5 flags; destination TTL is removed")
client.hset("hash", "field", b"value")
try:
client.setbit("hash", 0, 1)
except redis.ResponseError as error:
assert "WRONGTYPE" in str(error)
else:
raise AssertionError("a hash is not converted into a bitmap")
assert client.hget("hash", "field") == b"value"
print("bitmap commands reject a hash without converting or replacing it")
Bits zero and two produce byte 0xa0 and count two. Writing a distant zero still creates 1,048,576 payload bytes; toggling and clearing it leaves that length unchanged. Byte-range counting and bit-range counting return two and one respectively, while the bounded BITPOS result remains an absolute offset.
An all-one byte yields position eight for an unbounded zero search but -1 for a bounded search. OR produces five flags across two bytes and removes the destination TTL; AND produces two zero bytes. Wrong-type access leaves the hash intact. These results verify representation and command semantics, not total process memory, concurrent allocation correctness, or production throughput.
Sources
- Redis SETBIT, previous-bit replies and string growth, checked 2026-09-08.
- Redis GETBIT, missing-key and out-of-range reads, checked 2026-09-08.
- Redis BITCOUNT, population counts and byte/bit ranges, checked 2026-09-08.
- Redis BITPOS, absolute offsets and bounded zero searches, checked 2026-09-08.
- Redis BITOP, output strings, zero-padding, and operation cost, checked 2026-09-08.
- Redis 8.10.1 build instructions, core-only compilation and allocator selection, checked 2026-09-08.