Library / SDK
Doist/bitmapist avatar
Doist/bitmapist

Bitmapist: user analytics on Redis bitmaps, and what the ID range rule costs you

Powerful analytics and cohort library using Redis bitmaps.

1,010 stars76 forksPythonBSD-3-Clause

At a glance

What is it?
Bitmapist stores user events as Redis bitmaps keyed by period and event name, so set intersection and retention questions become bit operations rather than SQL. The design constrains your user IDs to a 32-bit range and expects a Redis-compatible store.
Who is it for?
Bitmapist suits a team with stable, dense user IDs that needs real-time active user counts, retention percentages and cohort intersections without running a column store, and that is comfortable with Redis as an analytics backend. It is a poor fit if your user identifiers are UUIDs or 64-bit IDs, since the library requires IDs in the range below 2 to the power of 32, and it is a poor fit if you need arbitrary dimensional slicing beyond the event and period model.
Can I use it commercially?
Yes. BSD-3-Clause is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 99 days ago.
What is it written in?
Mainly Python, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 24, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Events as bits, and the memory arithmetic behind it

Bitmapist is an analytics library for Redis that makes real-time, scalable analytics possible, and the whole thing rests on one representation choice. User activity is stored as a Redis bitmap, one bit per user, so marking user 123 as active is setting bit 123 in a byte string rather than inserting a row.

The consequence is the memory profile. The project states that you can store events for millions of users using a very little amount of memory, in the order of megabytes, because a bitmap of a million users is roughly 125 kilobytes per event period. That is a completely different order of magnitude from a table with a row per event, and it is the reason the library can answer a question like how many users have been active this month without a warehouse.

The cost of that representation is the ID constraint, and the README puts it under a caveat heading: you should be careful about using large IDs as this will require larger amounts of memory, and IDs should be in the range [0, 2^32). A bitmap is indexed by bit position, so the memory a bitmap occupies is proportional to the largest ID it contains, not to the number of users in it. If your largest user ID is 10 million, every bitmap for that event is about 1.2 megabytes even if only three users are active.

That is the single most important thing to check before choosing this library, because it interacts directly with how you mint identifiers. Sequential integer primary keys fit. UUIDs do not, and neither do snowflake IDs once they get large.

Marking events and asking whether a user was active

The API is small and the first call is a setup import. The library exposes setup_redis, delete_all_events, mark_event, the period classes MonthEvents, WeekEvents, DayEvents and HourEvents, and the bit operation classes BitOpAnd and BitOpOr.

Marking an event takes a name and a user id:

python
mark_event('active', 123)
mark_event('song:played', 123)

The event name is a free string, so an application can have as many event types as it likes, and each name is a separate bitmap family. Reading is done with the period classes, and the containment check is the core operation:

python
assert 123 in MonthEvents('active', now.year, now.month)
assert 123 in MonthEvents('song:played', now.year, now.month)
assert MonthEvents('active', now.year, now.month).has_events_marked() == True

Counting is the number of set bits, and iterating gives you the user ids:

python
iso_year, iso_week, _ = now.isocalendar()
print(len(WeekEvents('active', iso_year, iso_week)))

for uid in WeekEvents('active'):
    print(uid)

The event names in the example are namespaced with a colon, song:played, which is a convention worth keeping since the name is the only thing separating one event family from another in Redis.

If you are interested in current events you can omit the extra arguments, and the events are populated with the current time automatically. MonthEvents('active') and MonthEvents('active', now.year, now.month) are equivalent, and there is a from_date static method for building an event from any datetime object.

Walking through time with prev, next and delta

The period classes are designed so that the same code works across every granularity, from hour to year. Three methods handle the traversal. prev and next return the sibling events, so you can step backwards or forwards one period at a time, and delta jumps more than one step in either direction:

python
current_month = MonthEvents()
prev_month = current_month.prev()
next_month = current_month.next()
year_ago = current_month.delta(-12)

The delta call with -12 is the interesting one, because it gives you a year-ago cohort from the current month without any date arithmetic of your own. That is what makes retention queries readable.

Each event object also has period_start and period_end methods that return the time span of the event, and the documented use is caching. If you do not want to cache values for events in the future, you can check the end of the period before writing to your cache:

python
ev = MonthEvent('active', dt)
if ev.period_end() < now:
    cache.set('active_users_<...>', len(ev))

One default is worth knowing about. Tracking hourly is disabled by default, and the README says it is disabled to save memory. To enable it as the default you set bitmapist.TRACK_HOURLY = True, and you can also bypass the default per call with an extra argument to mark_event, such as mark_event('active', 123, track_hourly=False). Since hourly tracking is the granularity most people want for a real-time dashboard, and it is off by default, that flag is easy to miss.

Unique events, and combining them with dated ones

Some events are not really about time. A user being on the premium plan, or having signed up through a particular A/B test variant, does not have a date that means anything. Bitmapist models those with a separate UniqueEvents class, which creates only one Redis key and does not depend on the date.

The value shows up when you combine unique events with dated ones, because the two are different sets and the intersection is the answer:

python
active_today = DailyEvents('active')
a = UniqueEvents('signup_form:classic')
b = UniqueEvents('signup_form:new')

print("Active users, signed up with classic form", len(active & a))
print("Active users, signed up with new form", len(active & b))

That is a complete A/B test readout, computed as a bitmap intersection, with no second query pass and no join.

The same pattern works as a filter. Marking and unmarking unique events as a user's status changes means the bitmap is the current truth:

python
def premium_up(uid):
    mark_unique('premium', uid)

def premium_down(uid):
    unmark_unique('premium', uid)

active_today = DailyEvents('active')
premium = UniqueEvents('premium')

for uid in premium & active_today:
    add_extra_karma(uid)

And you can mark both kinds at once, with mark_event('premium', uid, track_unique=True), which is described as getting the best of both worlds: the event lands in the dated bitmap for the period and in the undated unique bitmap.

Bit operations are the whole point of the library

The questions the library is built to answer are set questions, and they are answered with bit operations. The canonical example is retention: how many users active last month are still active this month.

python
active_2_months = BitOpAnd(
    MonthEvents('active', last_month.year, last_month.month),
    MonthEvents('active', now.year, now.month)
)
print(len(active_2_months))

assert 123 in active_2_months

You can also use ordinary Python operator syntax, which reads better and does the same thing:

python
last_month_event = MonthEvents('active', last_month.year, last_month.month)
this_month_event = MonthEvents('active', now.year, now.month)
active_two_months = last_month_event & this_month_event

The supported operators are &, |, ^ and ~, and they compose, so you can nest a BitOpAnd inside another BitOpAnd to build a three-period intersection.

This is where the storage model pays off. A retention query that would be a self-join over an events table becomes a bitwise AND over two byte strings, executed inside Redis, with the result being a bitmap you can count, iterate and test for membership on. The cohort graphs the library advertises, retention percentages and the share of users who did X and also did Y over time, are all this same operation applied repeatedly.

One caveat about the operators is not documented, so it is worth working out for yourself: the result of an AND is the intersection of the two id sets, which is only the same as the set of users active in both periods if every bitmap is indexed by the same id space. Since the library requires ids below 2^32 and uses the id as the bit position, that holds as long as your ids are stable.

Redis, or the custom store the project points you to

The project makes a recommendation that is worth reading before you install anything. Instead of Redis as a backing store, consider using bitmapist-server, described as the project's custom data store that exposes a partial Redis-compatible API, fully compatible with Bitmapist. The claim attached to it is that it is 443x more memory efficient for this particular use case, improving scalability and cost effectiveness.

The framing tells you the trade. Redis is the default because it is a known quantity and you probably already run it. bitmapist-server is a specialised store built for exactly this workload, with a partial Redis-compatible surface, so the library keeps working unchanged while the storage underneath gets denser. If you are using Redis for many other things as well, adding a second store is a real operational decision, so the 443x figure only pays for itself at a scale where Redis memory is a line item worth attacking.

The development setup shows both running side by side. The compose file starts a Redis service from the redis:7-alpine image on a configurable port, and a bitmapist-server service pinned to a specific image version, each exposed on 6379 inside the container. The file comment says it is used to start the Redis and bitmapist-server services for local development and testing, so the library is expected to be developed against either.

The package itself is built with hatchling, requires Python 3.9 or newer and below 3.14, depends on redis 2.10 or newer and below 6.0, python-dateutil and Mako, and is BSD-3-Clause licensed with a Production/Stable development status. The dev group pulls in pytest, tox, mypy, pre-commit and ruff. The repository is not archived, and the last push was on 2026-07-17, with the most recent release v3.119 from 2026-01-07.

Editorial conclusion

Bitmapist suits a team with stable, dense user IDs that needs real-time active user counts, retention percentages and cohort intersections without running a column store, and that is comfortable with Redis as an analytics backend. It is a poor fit if your user identifiers are UUIDs or 64-bit IDs, since the library requires IDs in the range below 2 to the power of 32, and it is a poor fit if you need arbitrary dimensional slicing beyond the event and period model. Before adopting, check that your identifiers fit the 32-bit range, decide between Redis and the bitmapist-server store the project recommends, and remember that hourly tracking is off by default and has to be enabled explicitly.

Frequently asked questions

How do I install bitmapist and mark my first event?

Install it with pip install bitmapist, or with uv add bitmapist if you use uv. Then import mark_event and the period classes, and call mark_event('active', 123) to mark user 123 as active. Reading works through the period classes, for example 123 in MonthEvents('active', now.year, now.month).

What user ID range does bitmapist support and why does it matter?

IDs should be in the range [0, 2^32). A bitmap is indexed by bit position, so the memory it occupies depends on the largest ID it contains rather than the number of active users, and using large IDs therefore requires larger amounts of memory.

How do I compute retention with bitmapist?

Intersect two period bitmaps. You can use the BitOpAnd class or the standard Python operators, since &, |, ^ and ~ are supported, and nest them for three-way intersections. The result is a bitmap you can count with len or test with the in operator.

What are UniqueEvents for and how do they differ from the dated event classes?

UniqueEvents is for events where the date makes little or no sense, such as premium status or an A/B test variant. It creates only one Redis key and does not depend on the date, and it can be combined with dated events through the same set operators to answer filter questions.

Is hourly tracking enabled by default in bitmapist?

No. Tracking hourly is disabled by default to save memory. To turn it on as the default, set bitmapist.TRACK_HOURLY = True, and you can override it per call by passing track_hourly to mark_event.

Official sources

  1. Doist/bitmapist on GitHub
  2. Issues
  3. License: BSD-3-Clause
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/doist-bitmapist.svg)](https://hysenlabs.com/projects/doist-bitmapist)

Was this useful?