How I Ended Up Pricing Boats With Bitmaps And ETS

How I Ended Up Pricing Boats With Bitmaps And ETS

By Yuriy Zhar 6 min read
I wanted a booking search that felt instant, not “wait while I join five tables and cry”, so I ended up encoding availability and prices into tiny bitmaps and shoving them into ETS. Here’s how and why that works.

Why I Didn’t Just Use “Normal” Booking Schemas

When you start a booking system, the usual options show up quickly:

  • One row per night per boat: boat_id, date, price, status.
  • Ranges: from, to, day_price, weekly_price, status.
  • Some JSON blob of “calendar” per boat you parse every time.

I tried variations of all of these in my head and on paper. They work, but for search they hurt:

  • Per-day rows explode in size: year × boats × nights. You get big tables and always-on indexes.
  • Range-based models are compact but annoying to query: crossing New Year, overlapping promos, minimum charter rules, etc.
  • JSON calendars move the complexity to the app layer: you still end up rebuilding a “timeline” in memory on every search.

What I wanted instead:

  • Fast “are these dates free?” checks.
  • Fast “what is the total price for this span?” checks.
  • Cheap to keep in memory for thousands of boats.
  • Something I can store in ETS and slice in pure Elixir without round-tripping to the database.

That’s where bitmaps started to make sense.

Bitmaps In Plain Terms

Forget boats for a second. Take a single year. For availability I just want to know: for each day, is it free or busy?

  • Availability bitmap: one character per day, "0" for free, "1" for busy.
  • Price bitmap: one byte per day, <<0>> means “no price / unavailable”, any other byte is a tier code.

The important part: length is always days_in_year(year). No variable-length structure, no gaps. A whole year is a fixed-size string or binary.

Example for availability (simplified):

# Encode 2025-01-01..2025-01-10 as busy (1s), rest of the year stays free (0s)
bitmap =
  AvailabilityBitmap.encode(
    [%{from: ~D[2025-01-01], to: ~D[2025-01-10]}],
    2025
  )

# These dates fall inside the busy block → not available
AvailabilityBitmap.available_between?(bitmap, ~D[2025-01-05], ~D[2025-01-07])
# => false

# These dates are nowhere near the busy block → fully available
AvailabilityBitmap.available_between?(bitmap, ~D[2025-02-01], ~D[2025-02-10])
# => true

Under the hood it is literally slicing strings:

  • Convert dates to indices: idx = days since January 1.
  • Take a substring with start_idx..end_idx.
  • Check if it contains a “busy” marker or not.

Same idea for prices, just a bit more interesting. I do not store the actual numbers in the bitmap, only a one-byte tier code that points into a prices map:

# Tier lookup table: each 1-byte code points to day + weekly price
prices = %{
  <<1>> => %{day: 120_00, weekly: 700_00},  # code 1 → low season
  <<2>> => %{day: 150_00, weekly: 900_00}   # code 2 → high season
}

# Build a price bitmap for the year:
# - early June: low season (<<1>>)
# - August:     high season (<<2>>)
price_bitmap =
  PriceBitmap.encode(
    [
      %{from: ~D[2025-06-01], to: ~D[2025-06-14], code: <<1>>},
      %{from: ~D[2025-08-01], to: ~D[2025-08-31], code: <<2>>}
    ],
    2025
  )

Now to compute the price for a stay:

# Ask the bitmap to price a stay directly, no DB query involved
PriceBitmap.price(
  price_bitmap,
  prices,
  ~D[2025-06-03],
  ~D[2025-06-10]
)

# => {:ok, total_in_cents}

This function just slices the price bitmap and runs a tiny run-length algorithm:

  • Read the first byte (tier code), count how many consecutive days have this code.
  • Use the prices map to convert the run into money: weeks × weekly_price + leftover_days × day_price.
  • Repeat for the remaining bytes in the slice.

No database, no joins, no dynamic SQL. Just linear scans over a very small in-memory binary.

Where ETS Fits In

Bitmaps are nice, but they become really interesting when you combine them with ETS.

ETS gives me:

  • In-memory tables owned by the VM.
  • O(1) lookup by key when I have the full key.
  • :select with match specs when I want “give me all boats under this prefix”.
  • Concurrency: multiple processes hitting it without me hand-rolling locks.

The pattern I use is:

  • Key: a 5-tuple path like {zone, country, city, harbour, boat_id}.
  • Value: a map with precomputed availability and prices and whatever metadata I need for filtering.

Indexing a boat looks roughly like this:

defmodule Booking.Index do
  @moduledoc """
  Simple ETS-backed index for boats:
  - precomputes availability + price calendars
  - stores them under a 5-tuple key
  """

  alias Booking.{AvailabilityBitmap, PriceBitmap}

  @table :boat_index

  # Public API: take a boat struct/map and push it into ETS
  def index_boat(boat) do
    availability = AvailabilityBitmap.build(boat.blackout_ranges)
    prices       = PriceBitmap.build(boat.price_ranges)

    key = build_key(boat)

    # Value is a compact struct-like map the search code can work with
    :ets.insert(@table, {key, %{id: boat.id, availability: availability, prices: prices}})
  end

  # Build the canonical 5-segment key used in ETS
  defp build_key(boat) do
    {boat.zone, boat.country, boat.city, boat.harbour, boat.id}
  end
end

And then a search does something like this:

defmodule Booking.Search do
  @moduledoc """
  Read-only search over the ETS index:
  - pulls all boats (or a prefix)
  - filters by availability
  - filters by price range
  """

  @table :boat_index

  # High-level API, the thing your controller calls
  def search(opts) do
    from      = opts.start_date
    to        = opts.end_date
    min_price = opts.min_price
    max_price = opts.max_price

    @table
    |> select_all_boats()
    |> normalize_rows()
    |> filter_available(from, to)
    |> filter_by_price_range(from, to, min_price, max_price)
  end

  # Match-spec that says “give me full rows: key + value”
  defp select_all_boats(table) do
    match_spec = [
      {
        # {{zone, country, city, harbour, boat_id}, value}
        {{:"$1", :"$2", :"$3", :"$4", :"$5"}, :"$6"},
        # no guards, we want everything under the table
        [],
        # return all captured variables
        [:"$$"]
      }
    ]

    :ets.select(table, match_spec)
  end

  # Turn the raw `:"$$"` rows into a nicer `{key, value}` format
  defp normalize_rows(rows) do
    Enum.map(rows, fn [z, c, cc, h, b, v] ->
      {{z, c, cc, h, b}, v}
    end)
  end

  # Keep only boats that are free for the requested dates
  defp filter_available(rows, from, to) do
    Enum.filter(rows, fn {_key, v} ->
      Booking.Availability.available?(v.availability, from, to)
    end)
  end

  # Keep only boats whose total price is within [min_price, max_price]
  defp filter_by_price_range(rows, from, to, min_price, max_price) do
    # Price bitmap uses inclusive ranges, so end date becomes (to - 1)
    inclusive_end = Date.add(to, -1)

    Enum.filter(rows, fn {_key, v} ->
      case Booking.Prices.calculate(v.prices, from, inclusive_end) do
        {:ok, total} -> total >= min_price and total <= max_price
        _ -> false
      end
    end)
  end
end

Notice what is missing:

  • No call to a database in the hot path.
  • No ORM doing clever things.
  • No per-day loops per boat in plain Elixir; all the heavy work was done once when building the bitmaps.

Availability and price filters just call into the bitmap modules, which do slice-and-scan over tiny binaries. The ETS table is basically a big in-memory index of “boat calendar snapshots”.

How This Compares To Other Approaches

Let’s quickly line it up against some alternatives.

1. Per-day rows in the database

Classic design: for each boat and day you store a row with status and price.

  • Pros: easy to reason about; SQL can do all the filtering; works with any ORM.
  • Cons: big tables, big indexes, tons of random I/O under load; you end up caching aggressively anyway.

Price calculation for one stay is doable in SQL, but it gets gnarly when you mix daily and weekly pricing, promos, and overlapping rules. At some point you are writing low-level SQL that looks suspiciously like bitmap logic, just inside the database engine.

2. Date ranges only

Storing only ranges like “this promo is from X to Y” or “these dates are blocked”.

  • Pros: very compact; conceptually clean.
  • Cons: availability and price checks become “take the requested span, subtract all blocked spans, intersect all price spans, hope nothing overlaps weirdly”.

You can still make it work, but every new rule makes the range logic a bit nastier. It is also not constant-time to answer “is every day from A to B free?” – you always have to reason about the whole set of ranges.

3. JSON calendars per boat

Store one big JSON document with all days and metadata per boat.

  • Pros: easy to dump to clients; everything in one place; no joins.
  • Cons: you still need to deserialize the whole thing for each search or maintain a second representation for fast access.

It is basically a bitmap with extra steps and overhead.

4. Bitmap + ETS trade-offs

Bitmaps and ETS are not magic either:

  • You have to split by year, because each bitmap is year-scoped. Cross-year stays need to be broken into per-year chunks.
  • You need careful code to handle weird input and keep bitmaps valid length.
  • It is Elixir/BEAM specific: if you move stack or languages, you will reimplement it.

But once this is done, you get some very nice properties:

  • Memory is predictable: one year of availability is 365 or 366 bytes per boat, plus price bitmap and maps.
  • Checks are simple and fast: slice a string or binary, scan for “bad” markers, compute a few integer ops.
  • You can keep the whole calendar universe in ETS on each node and answer most questions without touching your main database.

In a search-heavy system, that is a big win: the database does slower, infrequent writes (updating calendars, ingesting new boats), while each API request mostly hits in-memory structures.

Why Bitmap + ETS Made Sense Here

I did not pick bitmaps because they are trendy or fancy. They just match the shape of the problem:

  • The calendar is naturally a sequence of days with simple states.
  • Prices are repetitive; there are a few tiers reused over many days.
  • Search needs lots of “read-mostly” operations with strict latency requirements.

Bitmaps give me an extremely compact representation of that calendar. ETS gives me a fast, shared store for those bitmaps and their metadata. Put together, I can:

  • Check availability for a date span with one function call over a few dozen bytes.
  • Compute total prices including weekly discounts without any SQL.
  • Filter thousands of boats in Elixir in a way that is understandable and debuggable.

Could I ship a booking engine without bitmaps and ETS? Sure. But if you are on Elixir, you get these tools for this kind of calendar-heavy search and they fit the job.

Windsurf
Recommended Tool

Windsurf

All my projects and even this website is build using Windsurf Editor. Windsurf is the most intuitive AI coding experience, built to keep you and your team in flow.

Share this article:
Yuriy Zhar

Yuriy Zhar

github.com

Passionate web developer. Love Elixir/Erlang, Go, Deno, Svelte. Interested in ML, LLM, astronomy, philosophy. Enjoy traveling and napping.

Get in Touch

If you need a developer who delivers fast, reliable, real-world solutions, reach out. Let’s turn your idea or project into something that works.

Stay updated

Subscribe to our newsletter and get the latest articles delivered to your inbox.