Databases

The Bitmap Index

One bit-vector per value — WHERE clauses become bitwise AND/OR/NOT

A bitmap index stores one bit-vector per distinct column value: bit i is set exactly when row i holds that value. A WHERE clause over these columns becomes a bitwise AND, OR, and NOT across the vectors — the CPU processes 64 rows per 64-bit word, so multi-predicate filters resolve almost for free. Invented for read-mostly analytics (Patrick O'Neil's Model 204 in the 1980s, then Oracle's implementation in the 1990s), bitmap indexes dominate low-cardinality columns in OLAP warehouses, and modern compression (WAH, EWAH, Roaring) keeps them compact across billions of rows.

  • Space (uncompressed)O(c · n) bits — c distinct values, n rows
  • Boolean query (AND/OR/NOT)O(n / w) words, w = 64
  • COUNT(*) on resultO(n / w) via hardware POPCNT
  • Best forLow-cardinality, read-mostly (OLAP)
  • Worst forHigh-cardinality + heavy DML (OLTP)
  • CompressionWAH / EWAH (run-length), Roaring (hybrid)

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

Why the bitmap index matters

Analytic queries are dominated by filtering. A dashboard asks "how many orders were status = 'shipped' AND region = 'EMEA' AND NOT channel = 'returns'?" — three predicates over three low-cardinality columns, combined with boolean logic. A B-tree index can answer each predicate individually, but combining three B-tree scans means intersecting three sorted lists of row IDs, an operation whose cost scales with the number of matching rows.

The bitmap index changes the representation so that combination is the cheap operation. Each predicate is already a bit-vector; ANDing three vectors of n bits is n/64 machine-word ANDs regardless of how many rows match. The final answer — the set of qualifying rows — is itself a bitmap, and counting the survivors is a population count. On a billion-row fact table this is the difference between a query that touches gigabytes of index entries and one that streams a few megabytes of packed bits through the CPU's ALU.

This is why every serious column-store and analytics engine — Oracle, DB2, Druid, ClickHouse, Apache Lucene/Elasticsearch, Apache Spark, Kylin — leans on bitmaps for filter evaluation.

How it works, step by step

Take a tiny customers table with a region column that has three distinct values: US, EU, ASIA.

row #   region     bitmap[US]  bitmap[EU]  bitmap[ASIA]
  0      US             1           0            0
  1      EU             0           1            0
  2      US             1           0            0
  3      ASIA           0           0            1
  4      EU             0           1            0
  5      US             1           0            0
  6      ASIA           0           0            1
  7      EU             0           1            0
  1. One bitmap per distinct value. The column produces exactly c = 3 bitmaps, each n = 8 bits long. In every bitmap, position i answers a yes/no question: "does row i equal this value?"
  2. Bitmaps are mutually exclusive and exhaustive. For a single-valued column, exactly one bit is set at each row position across the set of bitmaps. ORing all value bitmaps together yields the all-ones "not null" vector.
  3. Predicates become bit-vector lookups. region = 'EU' is simply bitmap[EU] = 01001001 (rows 1, 4, 7).
  4. Boolean logic is bitwise. region IN ('EU','ASIA') is bitmap[EU] OR bitmap[ASIA]. region != 'US' is NOT bitmap[US]. Combining columns — region='EU' AND premium=true — is bitmap[region=EU] AND bitmap[premium=true].
  5. Materialize or count. The result bitmap's set positions are the qualifying row IDs; iterate them to fetch rows, or POPCNT the words for a COUNT(*) without ever touching the base table.

Because each operation is word-parallel, the asymptotic cost of a full boolean expression over the index is O(n / w) word operations per bitmap involved, where w is the machine word width (64). There is no per-matching-row cost until you actually materialize rows.

Compression: WAH, EWAH, and Roaring

The obvious objection: a column with c distinct values costs c × n bits uncompressed. For 1 billion rows and 100 distinct values, that is 100 billion bits ≈ 12.5 GB — before compression. But real bitmaps are extremely sparse or clustered: for a value appearing in 1% of rows, 99% of the bits are zero, often in long runs. Run-length compression exploits exactly this.

  • WAH (Word-Aligned Hybrid). Splits the bit-stream into words. A word is either a literal (31 real bits) or a fill (a run-length of all-0 or all-1 words). Crucially, fills and literals stay word-aligned, so a WAH-compressed AND/OR runs word-at-a-time without decompressing — you skip aligned runs of zeros wholesale. Introduced by Wu, Otoo, and Shoshani (LBNL, FastBit).
  • EWAH (Enhanced WAH). Packs a marker word that describes a run of fills followed by a count of literal words, improving skip efficiency and random access. Used by Git (for reachability bitmaps) and by Apache Hive.
  • Roaring bitmaps. Partition the 32-bit row-ID space into 216 chunks of 65 536 positions. Each chunk is stored in the smallest of three formats: an array container (sorted 16-bit ints) when sparse (< 4096 set bits), a raw bitmap container (8 KB) when dense, or a run container when values form long runs. Roaring picks per-chunk, so it adapts to local density and typically beats WAH on both size and speed. It is the default bitmap format in Lucene, Druid, Spark, ClickHouse, and InfluxDB.

A key property preserved by all three: compressed AND/OR/NOT stay in compressed space. You never expand a billion-bit vector to operate on it — you walk aligned words or containers and emit compressed output. That is what makes bitmap indexes practical at warehouse scale.

Bitmap index vs B-tree index

Bitmap indexB-tree index
Ideal cardinalityLow (few distinct values)High (near-unique)
SpaceO(c · n) bits, compressed heavily when sparseO(n) entries, ~O(n log n) bits
Point lookup (one value)Load one bitmap, O(n / w)O(log n) tree descent
Multi-predicate AND/OR/NOTNative — bitwise, O(n / w) per vectorIntersect sorted row-ID lists, cost ∝ matches
COUNT / GROUP BYPOPCNT the result bitmapScan matching leaf entries
Range scan (a < x < b)OR many value bitmaps (or use binned/range-encoded bitmaps)Native — ordered leaves, one range walk
Single-row INSERT/UPDATE/DELETEExpensive — may re-encode a compressed run; coarse lockingO(log n) node split/merge, row-level friendly
Concurrency (OLTP)Poor — DML serializes on bitmap segmentsGood — designed for it
Sweet spotRead-mostly OLAP fact tables, star schemasOLTP, primary keys, ordered scans

They are complementary, not competitors. A star-schema warehouse typically puts bitmap indexes on the low-cardinality dimension foreign keys of the fact table (enabling bitmap join indexes and star transformations) while keeping B-trees on high-cardinality surrogate keys.

Encoding schemes: equality, range, and binning

The naive "one bitmap per value" is equality encoding and is optimal for equality predicates but weak for ranges (a range needs to OR many bitmaps). Two refinements matter for real workloads:

  • Range encoding. Bitmap k stores "value ≤ k" instead of "value = k". A range predicate a < x ≤ b then costs a constant two bitmap operations (B[b] AND NOT B[a]) instead of ORing every bitmap in the interval.
  • Binning. For higher-cardinality-but-still-bounded columns, bucket values into bins (e.g. price ranges) and index the bins. Queries that don't align with bin boundaries incur a candidate check ("false positive" verification) against the base data — the classic FastBit strategy for scientific data.
  • Bit-sliced / bit-plane encoding. Store each binary digit of the value as its own bitmap (⌈log₂ c⌉ bitmaps total). This supports arithmetic and range aggregation (SUM, range-count) with logarithmically many bitmaps — the basis of "bit-sliced indexing" (BSI) used for top-k and preference queries.

A worked query

Suppose a 1-billion-row events fact table with bitmap indexes on status (4 values) and device (3 values). We want:

SELECT COUNT(*)
FROM events
WHERE status IN ('ok','retry')
  AND device = 'mobile'
  AND status != 'error';

The optimizer rewrites this into pure bitmap algebra:

result = (B[status=ok] OR B[status=retry])
         AND B[device=mobile]
         AND NOT B[status=error]
answer = popcount(result)

Four billion-bit vectors are combined with three bitwise ops and one popcount. Compressed, each vector is a few megabytes; the entire query never reads a single base-table row. Contrast a B-tree plan, which would resolve each predicate to a row-ID set and intersect them, paying per matching row.

Implementation: a minimal bitmap index in Python

class BitmapIndex:
    """Equality-encoded bitmap index over one column.
    Each distinct value maps to a Python int used as an arbitrary-width bit-vector."""

    def __init__(self):
        self.bitmaps = {}   # value -> int (bit i set if row i has this value)
        self.n = 0          # number of rows indexed

    def add(self, value):
        row = self.n
        # set bit `row` in this value's bitmap; create it if new (new distinct value)
        self.bitmaps[value] = self.bitmaps.get(value, 0) | (1 << row)
        self.n += 1

    def eq(self, value):
        return self.bitmaps.get(value, 0)

    def neq(self, value):
        full = (1 << self.n) - 1            # all-ones mask over n rows
        return full & ~self.bitmaps.get(value, 0)

    def any_of(self, values):
        r = 0
        for v in values:
            r |= self.bitmaps.get(v, 0)     # OR
        return r

    @staticmethod
    def count(bits):
        return bin(bits).count('1')         # popcount

# --- usage ---
regions = ['US','EU','US','ASIA','EU','US','ASIA','EU']
idx = BitmapIndex()
for r in regions:
    idx.add(r)

# WHERE region IN ('EU','ASIA') AND region != 'US'
result = idx.any_of(['EU','ASIA']) & idx.neq('US')
print(idx.count(result))          # -> 5   (rows 1,3,4,6,7)
print(f"{result:08b}"[::-1])      # -> 01011011  (bit i = row i)

Python's big integers make every set operation a single &, |, or ~, and bin(bits).count('1') is the popcount. A production engine replaces the arbitrary-width int with word-aligned arrays plus WAH/Roaring compression, and the popcount with the CPU's hardware POPCNT instruction, but the algebra is identical.

The update problem

Bitmap indexes are cheap to read and expensive to write. Changing one row's status from ok to error flips two bits: clear bit i in B[ok], set bit i in B[error]. That is trivial on an uncompressed bitmap — but on a compressed one, flipping a bit that sits inside a long compressed run splits that run, turning one word into up to three and forcing a re-encode of the surrounding segment. And many engines take a coarse lock on the affected bitmap segment during DML, so concurrent updates serialize.

The practical consequences:

  • Oracle explicitly recommends bitmap indexes for data-warehouse and decision-support tables and warns against them on tables with heavy concurrent OLTP, where the coarse locking causes contention and deadlocks.
  • Analytics engines sidestep updates entirely by treating data as immutable segments: Druid, Lucene, and Parquet-based stores build bitmaps at ingest time per segment and never mutate them; deletes are handled by a separate "deletion bitmap," and updates are append-plus-tombstone, compacted later.
  • Deferred / batch maintenance — rebuild or bulk-merge bitmaps offline — is the norm for warehouse ETL, which is bulk-load-then-query anyway.

Common misconceptions and pitfalls

  • "Bitmaps are only for boolean columns." False — they work for any discrete column; you just get one bitmap per distinct value. Cardinality, not type, is the deciding factor.
  • "High cardinality is fine because compression saves you." Compression shrinks each sparse bitmap, but you still manage c bitmaps and their metadata; when cn the per-value overhead and lookup dispatch dominate, and a B-tree is better.
  • "Bitmap indexes speed up single-row OLTP lookups." They can, but the update cost usually makes them a poor fit for write-heavy transactional tables. Reach for a B-tree there.
  • "NULLs don't need a bitmap." They do if you want IS NULL to be fast; some engines maintain an explicit NULL bitmap so the value bitmaps stay exhaustive.
  • "Range queries are hopeless on bitmaps." Naive equality encoding is weak on ranges, but range-encoded or bit-sliced bitmaps turn a range into a constant number of operations.
  • "A bitmap index and an inverted index are different things." They are duals — the same value→rows mapping stored as a dense vector vs. a sparse list. Engines switch representation based on term density.

A little history

The bitmap index traces to Patrick O'Neil's work on the Model 204 DBMS in the late 1970s and 1980s, which used bit-vectors for set-valued query evaluation. The idea entered mainstream relational databases when Oracle shipped bitmap indexes in Oracle 7.3 (1996) and built the star-transformation optimizer around them. Compression research at Lawrence Berkeley National Laboratory produced WAH and the FastBit library for scientific data in the 2000s. In 2016, Daniel Lemire and collaborators published Roaring bitmaps, which quickly became the de-facto standard container format across the open-source analytics stack — Lucene, Druid, Spark, ClickHouse, and even Git's on-disk reachability bitmaps all rely on the run-length/hybrid ideas pioneered here.

Frequently asked questions

What is a bitmap index in a database?

A bitmap index stores, for each distinct value of a column, a bit-vector (bitmap) with one bit per row: bit i is 1 if row i holds that value, else 0. A column with c distinct values produces c bitmaps, each n bits long for a table of n rows. Queries evaluate WHERE predicates as bitwise AND, OR, and NOT over these vectors, processing 64 rows per 64-bit machine word — extremely fast for the low-cardinality, read-mostly columns typical of a data warehouse.

When should you use a bitmap index instead of a B-tree index?

Use a bitmap index on low-cardinality columns (gender, status, country, boolean flags) in read-mostly or append-only analytic tables, especially when queries combine several such predicates with AND/OR. Use a B-tree index on high-cardinality columns (primary keys, timestamps, emails) and on OLTP tables with frequent single-row inserts, updates, and deletes. B-trees keep an O(log n) sorted structure that supports range scans and point lookups; bitmaps trade that for near-free boolean combination but pay heavily on updates.

Why are bitmap indexes bad for high-cardinality columns?

A distinct value needs its own bitmap. On a column with c distinct values and n rows, the uncompressed index is roughly c × n bits. When cardinality c approaches n — like a primary key — you get n nearly-empty bitmaps totaling about n² bits, which is catastrophic. Compression helps because each bitmap is extremely sparse, but you still store per-value metadata and lose the density that makes bitmaps fast. High cardinality is exactly where a B-tree wins.

How does WAH or Roaring compression make bitmaps smaller?

Word-Aligned Hybrid (WAH) and its successor EWAH run-length-encode long runs of all-zero or all-one words while keeping literal words word-aligned, so AND/OR/NOT still run word-at-a-time without decompression. Roaring bitmaps instead partition the 32-bit key space into 2^16 chunks and store each chunk as an array of 16-bit integers (sparse), a raw bitmap (dense), or a run list (long runs), picking whichever is smallest. Roaring usually beats WAH on both size and speed and is the default in Lucene, Druid, Spark, and ClickHouse.

Why is updating a bitmap index expensive?

Changing one row's value flips a bit in the old-value bitmap and the new-value bitmap. On a compressed bitmap that lives inside a run, flipping a single bit can split one compressed run into three words, forcing a re-encode of the affected chunk. Worse, many engines lock the entire bitmap (or a large segment of it) for the update, so concurrent DML serializes. That is why bitmap indexes are recommended for read-mostly warehouses and discouraged on hot OLTP tables — Oracle explicitly warns against them for tables with heavy concurrent inserts and updates.

How does a bitmap index count rows without scanning them?

COUNT(*) with a bitmap predicate is a population count (popcount) over the result bitmap — the number of set bits. Modern CPUs have a hardware POPCNT instruction that counts set bits in a 64-bit word in one cycle, so counting billions of qualifying rows reduces to a linear scan of the compressed bitmap words, never touching the base table. GROUP BY aggregation similarly ANDs each group's bitmap with the filter and popcounts the result.

What is a bitmap index vs an inverted index?

They are the same idea at different densities. An inverted index maps each value (or term) to a posting list of the row/document IDs that contain it; a bitmap index is that posting list stored as a dense bit-vector instead of a list of integers. Search engines like Lucene store postings as compressed integer lists for sparse terms and switch to bitmaps (Roaring) for dense terms — the two representations are duals, and the engine picks whichever is cheaper for a given term's document frequency.