Introducing ZenDB: Embedded Database with MongoDB-Style Queries for Motoko

Hey everyone, I’m excited to share a new motoko library:

ZenDB, an embedded document database that leverages stable memory to store and query large datasets efficiently. It provides a familiar document-oriented API, similar to MongoDB, allowing developers to store nested records, create collections, define indexes and perform complex queries on their data.

Links

The Problem

The Internet Computer’s heap is limited to 6GB, forcing data heavy applications toward multi-canister architectures which comes with their own set of complexities. Beyond capacity constraints, building data-heavy applications requires manually managing stable memory (layouts, allocations and serialization), choosing the right data structures for each of your query patterns, and implementing custom querying and indexing logic.

How ZenDB Solves This

ZenDB uses stable memory as its main data storage, providing up to 500GB capacity per canister. This unlocks the ability for data-heavy applications to use a simpler single-canister architecture. Instead of choosing between B-trees, hash maps, and creating custom indexes, you define your schema and let ZenDB handle the rest: efficient serialization, query planning, and index management behind a simple API.

Key Features

  • Indexes: Support for multi-field indexes to speed up complex queries
  • Full Candid Integration: Native support for Candid that allows you to insert and retrieve any motoko data type without creating custom serialization or type mappings
  • Rich Query Language And Query Builder: Comprehensive set of operators including equality, range, and logical operations with an intuitive fluent API for building queries
  • Query Execution Engine: Performance optimized query planner programmed to search for the best execution path that minimizes in-memory data processing.
  • Pagination: Supports both offset and cursor pagination
  • Partial Field Updates: Update any nested field without having to re-insert the entire document
  • Schema Validation And Constraints: Add restrictions on what specific values can be stored in each collection

Real-World Test: ICP Txs Archive

To validate ZenDB works at production scale, I built a live dapp that indexes all 31M ICP transactions in a single canister. It fetches ICP transactions from the ledger canister periodically and indexes them using ZenDB.

Links:

Setup: I created a Remote canister DB that exposes all the ZenDB methods. Then I set up a recurring timer function in the backend canister that calls the ledger canister every 5 minutes, and fetches the next batch of transactions since the last indexed one.

Performance: I spent about 3 weeks experimenting with this setup, trying to prevent as much downtime as possible. I hit the instruction limit a few times during indexing, causing the canister to stop. I also paused indexing a few more times to test different index configurations and benchmark them on various queries to find the ones that worked best. Eventually, I was able to get to a stable indexing rate of about 5000 txs / min. Without all the interruptions, it would have taken the dapp about 4 and a half days to index all 31 million transactions.

Cost: According to CycleOps, the total cost to index all transactions was 490 TC (~470 TC for the database canister, ~20 TC for the backend).

Storage: The collection uses about 32 GB of stable memory to store all the transaction records and indexes. However, the canister is currently 44 GB because it retains the allocated pages of some deleted indexes. The library also uses some heap memory (about 1.7 GB here) for document caching, in memory data processing, document validation, serialization and index key computation.

Queries: The backend exposes a function called v1_search_transactions() that handles queries from the frontend to the DB canister which uses these 8 compound indexes to speed up the execution of those queries.

What’s Next?

:double_exclamation_mark: Feel free to stop here if you’re not interested in the technical details! :double_exclamation_mark:
The rest of the post dives into the technical architecture, design decisions and performance of ZenDB. If you’d rather:

Architecture Design

ZenDB is a stable instance that consists of multiple databases, which in turn contain multiple collections. The actual data and indexes are stored in collections, and databases act as namespaces for grouping related collections.

Each collection uses a MemoryB+Tree for the document storage and each document is assigned a unique ID. To speed up queries, you can create secondary indexes (also MemoryB+Trees) that store concatenated field values as keys and document IDs as values, for quick range scans and lookups that point back to the original document.

There’s no hard limit to the number of databases, collections, or indexes that can be created. However, each index increases the instruction cost of insertions.

Internal Query Workflow

When executing a query, ZenDB follows this optimized workflow:

  1. The query is parsed and validated against the collection schema
  2. The query plan generator analyzes available indexes and query patterns, picking the best one for each group of filter and sort conditions.
  3. For indexed queries, the system chooses between:
    • Index Scan: Direct B-tree traversal for equality or simple range queries
    • Hybrid Approach: Combining index scans with in-memory filtering for complex queries
    • Union or Intersection: Merging results from multiple index scans (usually required for nested #And and #Or operations)
      • Bitmap: Loads IDs from each index scan into independent bitmaps and finds the intersection between them
      • K-merge: Loads IDs from each index scan into iterators, compares values at the top of all iterators, and returns values in sorted order
  4. Results are processed, sorted if needed, and paginated according to query parameters

Finding the union or intersection of multiple index scans allows ZenDB to handle complex queries efficiently, even with large datasets, by minimizing the amount of documents that need to be deserialized for internal filtering and sorting.

Important Note: Currently, no automatic indexes are created for your data. You’ll need to create your own indexes when defining your collection and schema. If no indexes exist that can satisfy a query, ZenDB will run a full collection scan, which is likely to hit the instruction limit for a dataset with as little as ten thousand records.

Orchid - Order-Preserving Encoding

ZenDB uses a custom order-preserving encoding format called Orchid to serialize index keys. This allows us to create composite index keys that maintain the sort order of multiple fields for efficient range scans and lookups in the B-tree indexes.

Orchid supports all Motoko primitive types in addition to Option, Text, and Blob types. Complex types like Record and Array are not supported as index fields, and Variant types are only partially supported (only the tag is indexed, not the inner value).

Unbounded types like Text and Blob are prefixed with their length to ensure correct ordering. The length prefix takes up two bytes, which means the maximum size for indexed Text and Blob fields is 65,535 bytes. The Principal type uses a one-byte length prefix, limiting indexed Principal fields to 255 bytes.

Unbounded Nat and Int types are converted to Nat64 and Int64 respectively before being encoded. While this is simpler to implement than a fully unbounded encoding, it does limit the maximum storable value. This implementation is sufficient for most cases, but we can extend the encoding format to support unbounded number types if there’s enough demand, as it’s designed to be extensible.

Here’s a link to the Orchid source code. I’ll also make this module its own Mops library if there’s interest.

Pagination: Cursor vs Offset

ZenDB supports both offset and cursor pagination, but they have different performance characteristics.

Offset pagination lets you define skip and limit parameters:

ZenDB.QueryBuilder()
  .Where("age", #lt(#Nat(18)))
  .Skip((page - 1) * page_size)
  .Limit(page_size)

However, offset pagination becomes problematic at scale because although you receive the results after the offset, the database must still process all documents prior to your offset position before returning the requested results. Given enough entries in your database, your pagination results will eventually hit the instruction limit as all the preceding documents are still processed. And as a result, you would not be able to retrieve all the pages for your query.

Here’s an example of two queries using offset pagination:

Cursor pagination solves this by returning a pagination token in the search response:

// First request
let #ok(res) = collection.search(
  ZenDB.QueryBuilder()
    .Where("age", #lt(#Nat(18)))
    .Limit(10)
);

Debug.print(res.documents); // first page

// Next request
let #ok(res) = collection.search(
  ZenDB.QueryBuilder()
    .Where("age", #lt(#Nat(18)))
    .Limit(10)
    .PaginationToken(res.pagination_token)
);

Debug.print(res.documents); // next page

The token encodes the query state, eliminating the need to reprocess earlier documents. You can traverse the entire result set by making consecutive calls for the next page without ever hitting the instruction limit. This eliminates the ability to randomly access pages while providing reliable pagination that scales with large datasets.

We can retrieve the documents in the previous offset query that failed using cursor pagination. To do this we just set the limit to a number that can be executed within the instruction limit. I’m gonna do 3000 because we know that those transactions can be retrieved by the offset pagination.

  • ZenDB Indexing Test
    After the first result, retrieve the next set of results by clicking next or page 2 and the txs between 3990 and 4000 that we couldn’t retrieve before will be included in the results.

Current Scope & Limitations

  • Single Sort Field: You can only sort by one field; multi-field sorting is planned
  • No Aggregations: Sum, count, and other aggregation functions are not yet supported
  • Limited Array Support: Arrays can be stored but cannot be indexed or have operations performed on nested elements
  • No Full-Text Search: Text-based full-text search and pattern matching within indexes is not supported
  • Complex OR Queries: Queries with many OR conditions may have suboptimal performance due to the internal merge required between independent index scans on different indexes
  • Query Planner: May not always select the optimal index for very complex queries (recommend benchmarking and adjusting indexes accordingly)
  • Schema Updates: Schema migrations are not yet supported; changing an existing collection’s schema requires creating a new collection and migrating data manually
  • No Built-In Backups: Internal and external canister backups are not currently supported
  • Supports Ascending Index Fields Only: Queries in descending order are supported, but indexes can only be created in ascending order at this time. We use reversible iterators internally, so there is no performance difference when querying in descending order.

Challenges & Design Decisions

Serialization Overhead

Storing data in ZenDB involves Candid encoding and schema validation. Compared to a simple B-tree without these internal data mutations and safeguards, ZenDB uses approximately 10x more instructions per insertion.

This overhead is inherent to any database system. While serialization is known to be expensive and has traditionally only been applied to JSON and Candid decoding of select entries, ZenDB requires it for all entries in order to access nested record fields during query execution and indexing. The serialization library has been optimized as much as possible, though native Blob.concat() and Blob.slice() operations in Motoko could significantly improve performance. Many serialization operations add bytes to a buffer and convert them to blobs, which these functions could replace directly.

The 10x overhead is acceptable given the benefits: complex queries, stable memory scaling, and index-driven performance gains. Whether this trade-off is worthwhile depends on your use case and access patterns.

Choose ZenDB if:

  • You need stable memory capacity
  • You need to create complex queries on your data
  • Your workload is read-heavy: historical data, logs, analytics dashboards, and time-series data that rarely change but require frequent querying benefit significantly from ZenDB’s indexing
  • You want the simplicity of a single canister architecture

Consider a heap B-tree if:

  • Instruction cost is critical and your data fits in 6GB
  • Your access patterns are simple key-value lookups with no need for complex queries

Memory Page Deallocation

When testing different index configurations for the txs_archive, I experimented to find the optimal set. Some indexes proved less useful and were deleted. Currently, Motoko doesn’t support deallocating pages within stable memory regions. As a result, the canister continues to pay for the memory of those deleted indexes.

To mitigate this, deallocated regions are tracked in a list to be reused by future indexes and collections. However, this only prevents future waste; existing unused pages remain allocated.

The txs collection stores 32 GB of actual data, but the total allocation is 45 GB, leaving 13 GB unused from deleted indexes. To avoid this, benchmark your index strategy on a small subset of your data before committing to indexing millions of documents.

Memory Collection & Supporting Libraries

ZenDB uses the memory-collection library, which provides three stable memory data structures: MemoryBuffer, MemoryBTree, and MemoryQueue.

Building on the original MemoryBuffer post, I’ve moved it into the memory-collection lib and improved the performance of the MemoryRegion allocator used for managing freed memory blocks. It now uses a single MaxValue B+Tree instead of two separate B-Trees, and switched from best-fit to worst-fit allocation. This change reduces the number of tree traversals required per allocation and retrieves the largest free block at constant time, making reallocation faster than best-fit. Additionally, I added support for merging adjacent free blocks to reduce fragmentation over time.

Several supporting libraries were also improved or created during development:

  • Serde: Added a TypedSerializer module that uses 50 and 80% fewer instructions for repeated serialization/deserialization on the same data type
  • BitMap: Faster set operations for index intersection and union
  • ByteUtils: Fast number to byte conversions and vice versa. Including order-preserving byte serialization for maintaining sorted order in blob form.
  • LruCache: Added support for custom eviction handlers

These are published on Mops and available for use in other projects.

What Helped During Development

Mops: Made benchmarking and local testing between libraries trivial.

VSCode Motoko Extension: All the features from this extension makes Motoko feel like any of the more mature languages and it’s a bliss to code in.

CycleOps: Handled automatic top-ups across multiple canisters, which was essential for backfilling txs_archives.

Motoko Language: Massive improvements from the motoko team. Some I would like to highlight:

  • Accessing the canister ID in the top-level of the actor class is a lot cleaner than exposing a public function that’s called during initialization.
  • The new migration syntax explicitly handles schema upgrades for non-mutable stable variables, allowing you to retain the same variable name across versions
  • Recent additions of explodeNatX and native Blob.get() primitives significantly reduced the performance overhead of blob operations

Next Steps

I plan to continue testing ZenDB, improving its performance and adding new features. If ZenDB sounds like it’s right for your use case, give it a try and let me know if you encounter any bugs or have any feature requests. Feel free to open an issue on GitHub or add them in this forum post. Any suggestions, feedback, or contributions are welcome as well.

Really impressive. Looking forward to trying it out.

Awesome work @tomijaga ! 32 GB is a massive canister.

Love this idea for a demo - it’s proof of a single canister architecture replacing the ledger suite “pokes @marc0olo “. Are you able to share the code for the database canister?

Were you able to test out the cursor performance of the Memory BTree at scale, especially if the data is split across multiple pages? From the demo the cursor pagination looks pretty good! It takes about ~1-1.5 seconds to load a page, but it’s honestly much better than I expected for stable memory.

Also, I hit a small error in the FE demo when changing one of the filters

What’s the memory overhead per new entry (new node inserted)? Just trying to get a ballpark idea of how many entries got you to 32GB, and what the format/size of those entries were.

You mentioned that with 32GB inserted, 44GB ended up utilized due to deletions. Were these deleted nodes? If I remember correctly stable memory utilization can only grow, so do you have any tips or tricks for reclaiming that unused memory as more new entries are inserted?

Glad to hear you found CycleOps helpful :folded_hands:

Cool stuff @tomijaga, I remember you sharing me a short demo a while ago. Glad to see that you continued developing on this! :+1:

Please also provide a PR to GitHub - dfinity/awesome-internet-computer: A curated list of awesome projects and resources relating to the Internet Computer Protocol :folded_hands:

At DFINITY we are still in the Rust land for the ledger suite. Anyway, I am also happy to poke some folks regarding this topic again (cc @mathiasb @bogwar @ielashi @gregory-demay).

Maybe @skilesare is also interested in exploring this in Motoko.

Ahhh…this is awesome! Excited to take a look at it when I can. We really need a clean, simple db approach! CanDB was super great, but was more focused at external applications pulling data out of the IC…this looks like it might be better for intercanister stuff. The API looks clean as well! I have a number of places where something like this would be useful.

As @icme mentions, some comparative cycle usage and memory footprint comparison to the new core Map and other structures would be informative. The ability to scale is likely worth the cost, but it would be good to know what those costs are!

I’m curious how the expansion of the heap available to orthogonal memory would affect this? I’d imagine you’d want the core of your data to remain in stable memory, but to what extent would indexes and clustered keys benefit from being orthogonal in the heap?

Thanks! Yes, 32 GB is quite large. The BTree for the transactions (document store) actually only takes up about 8.2 GB of space, while the remaining 24 GB is used by indexes. The memory distribution is available on the txs-archive site in the Collection Stats section, but I’ll share it here as well. Each transaction is about 260 bytes, and multiplied by 31M transactions we get ~7.8 GB. The remaining 400 MB is the BTree’s memory overhead (internal nodes and pointers).

Are you able to share the code for the database canister?

Yes, the code for the database canister is in the ZenDB repo. It’s in the RemoteInstance directory here: ZenDB/src/RemoteInstance/CanisterDB/lib.mo at main · NatLabs/ZenDB · GitHub

Were you able to test out the cursor performance of the Memory BTree at scale, especially if the data is split across multiple pages? From the demo the cursor pagination looks pretty good! It takes about ~1-1.5 seconds to load a page, but it’s honestly much better than I expected for stable memory.

For testing performance at scale, I created a query like this one with a result size of about 25M txs, set the page size to 3000, and retrieved the first 10 pages. Then I checked the query performance on the site, specifically the instructions used for each DB search query.

The performance goal is to verify we can retrieve all subsequent pages without hitting the instruction limit. With offset pagination, instructions accumulate with each page (each page adds to the total instruction). With cursor pagination, we expect instructions to remain roughly constant or within a small percentage of the first page’s instruction count.

For the first 10 pages I got: 2.63B, 2.77B, 2.70B, 2.49B, 1.58B, 2.30B, 2.67B, 2.79B, 2.85B, 2.68B.

From these results, we should be able to retrieve all pages in this query without hitting the instruction limit.

Also, I hit a small error in the FE demo when changing one of the filters

Thanks, I’ll take a look later and try to fix it.

What’s the memory overhead per new entry (new node inserted)? Just trying to get a ballpark idea of how many entries got you to 32GB, and what the format/size of those entries were.

Currently, the total memory overhead (shown as “btree metadata” in the screenshot below) is about 3.9 GB with roughly 400 MB from each MemoryBTree.

You mentioned that with 32GB inserted, 44GB ended up utilized due to deletions. Were these deleted nodes? If I remember correctly stable memory utilization can only grow, so do you have any tips or tricks for reclaiming that unused memory as more new entries are inserted?

No, these were entire BTrees that were deleted. For deleted nodes within a BTree, each MemoryBTree has a MemoryRegion that’s used to manage and reallocate freed memory blocks.

Yh, it was pretty bare bones back then. It’s a lot more developed now.
Thanks, I just opened a PR to add it to the list: Add the Memory-Collection and ZenDB Motoko projects by tomijaga · Pull Request #239 · dfinity/awesome-internet-computer · GitHub

ZenDB Updates

Hey everyone! There’s been a few changes made to ZenDB since the initial release. There are some new features, new tools for working with ZenDB canisters, and a handful of memory and performance improvements under the hood. Here’s a summary of everything that’s changed:

  • Added support for Text Indexes and Text Search Queries
  • Published a new CLI (zendb-cli) for creating and managing ZenDB canisters from the terminal
  • Released a TypeScript client library (zendb-client) for JavaScript/TypeScript apps
  • Remote ZenDB Canister WASM builds are now released via GitHub releases
  • Added fine-grained access control with role-based permissions at the database, collection, or canister level
  • Updated the underlying MemoryBTree to support prefix key compression, reducing memory usage for repeat key patterns by 11–20%.
  • Removed the type section from each encoded candid document, reducing the memory overhead of the main document store by 40% and more depending on the schema.

ZenDB CLI

The CLI is the fastest way to get a ZenDB canister running without writing any Motoko code. From the terminal, you can create and manage canisters, databases and collections, insert and query documents, and monitor stats and memory usage.

You can install the CLI globally with npm: https://www.npmjs.com/package/zendb-cli

npm install -g zendb-cli

Key commands:

# Import your dfx identity (stored securely in OS keychain)
zendb user import --data "$(dfx identity export <identity-name>)" --mode keyring

# Deploy a new ZenDB canister
zendb canister create dev --ic --release latest

# Register an existing canister
zendb canister add dev <CANISTER_ID> --ic

# Or deploy a local canister for development
zendb canister create dev --local --release latest

# Create a database and collection
zendb db create myapp --canister dev
zendb collection create users --canister dev --db myapp --schema 'record { name: text; age: nat }'

# Insert and query documents
zendb document insert --canister dev --db myapp --collection users --data '{"name":"Alice","age":30}'
zendb document insert --canister dev --db myapp --collection users --data users.json
zendb document search --canister dev --db myapp --collection users
zendb document list   --canister dev --db myapp --collection users --limit 10

# Monitor canister memory and stats
zendb canister stats dev

Identities are stored in the OS keychain by default (--mode keyring) or encrypted with a password (--mode password), so your private key never sits in plaintext on disk.

Any missing flags are prompted for interactively by the CLI. For example, running zendb collection create users without --schema, --db, or --canister set will prompt you to select the db or canister from the existing list, and launch an interactive schema builder before executing the command.

Remote Canister Releases

Remote canisters are now released via GitHub releases. zendb release pull fetches the WASM and caches it locally, so you can deploy to any network without building from source.

# List the releases
zendb release list

# Pull the latest release canister WASM to cache
zendb release pull latest 

# Remove a cached release
zendb release remove v2.0.0

# Deploy a canister with a specific release
zendb canister create dev --ic --release v2.0.0

This release system will allow you to easily upgrade your canister to the latest version of ZenDB with minimal downtime and no data loss. When a new release of the same major version is available (e.g. v2.0.0v2.1.0), you can run zendb canister upgrade dev --release v2.1.0 to seamlessly upgrade the canister WASM while preserving all the data and indexes in stable memory.

Access Control

Canister owners can now grant or revoke access at the database, collection, or canister level. The permission model supports these four roles:

  • admin: Full access to all operations and resources. Can manage other identities and their permissions.
  • observer: Read-only access to roles and permissions granted to other identities at the same scope. Useful for a registry canister that allows users to easily view all the canisters they have access to, without exposing any document data.
  • writer: Can read and write to existing resources, but cannot manage create or delete them.
  • reader: Read-only access to existing resources. Cannot perform write operations or manage resources.

When granting roles, you can limit the user’s access to a specific scope: --db for a database, --db --collection for a collection, or --global for canister-wide access. Omitting --global when no other scope is set will prompt you to confirm before applying global access.

# Grant writer access to a specific database
zendb role grant writer <IDENTITY> --db myapp --canister dev

# Grant reader access to a specific collection
zendb role grant reader <IDENTITY> --db myapp --collection users --canister dev

# Grant observer access globally (useful for a db registry that allows users to easily view the canisters they have access to)
zendb role grant observer <IDENTITY> --global --canister dev

# Revoke access
zendb role revoke writer <IDENTITY> --db myapp --canister dev
zendb role revoke reader <IDENTITY> --db myapp --collection users --canister dev
zendb role revoke observer <IDENTITY> --global --canister dev

TypeScript Client

For apps that need to talk to a ZenDB canister programmatically in JavaScript, there’s now a TypeScript client library published on npm: https://www.npmjs.com/package/zendb-client

npm install zendb-client
import { connectToZenDBClient, IDL, idlTypeToSchema, QueryBuilder, Q } from 'zendb-client';

const client = await connectToZenDBClient('your-canister-id', identity);
const db = client.database('myapp');

const users = await db.createCollection('users', IDL.Record({ name: IDL.Text, age: IDL.Nat }));

await users.insertJson({ name: 'Alice', age: 30n });

const results = await users.search(
  new QueryBuilder().Where('age', Q.gte({ Nat: 18n })).Limit(10)
);

See the full guide on npm for more examples.


Text Indexes

ZenDB now supports inverted text indexes for phrase, prefix, and keyword searches on text fields.

Creating a Text Index (Motoko)

// Create a text index on one or more fields
let #ok(_) = users.createTextIndex("bio_text_idx", ["bio", "name"]);

Internally, a text index is an inverted index that maps keyword tokens to document IDs. Because every keyword gets its own entry rather than a single entry for the full field value, text indexes carry more overhead than regular indexes. It’s best to only use them on fields that actually need text search, and stick to regular indexes for fields that only need range or equality queries.

To keep overhead manageable, each collection is limited to one text index, but it can still cover as many fields as you need. Consolidating fields this way results in better compression than splitting them across separate indexes.

Querying with Text Search

// Find documents where bio contains the phrase "motoko developer"
let #ok(results) = users.search(
  ZenDB.QueryBuilder()
    .Where("bio", #text(#phrase("motoko developer")))
    .Limit(20)
);

// Match any of several keywords
let #ok(results2) = users.search(
  ZenDB.QueryBuilder()
    .Where("bio", #text(#anyOf(["motoko", "rust", "icp"])))
    .Limit(20)
);

Text queries compose naturally with the existing query language, so you can combine them with range filters, equality checks, and #And/#Or operators.


let #ok(results3) = users.search(
  ZenDB.QueryBuilder()
    .Where("name", #text(#startsWith("Ali")))
    .And("age", #gt(#Nat(25)))
);

Each document returned from a text search includes a list of TextMatch records alongside the document that contains the keyword, field, keyword position, start and ending char offsets of the matched word. This makes finding the matched term in the document straightforward for things like highlighting search results in a UI.

let #ok({ documents }) = users.search(...);

for ((id, record, matches) in documents.vals()) {
  for (match in matches.vals()) {
    Debug.print(
      "Matched '" # match.word # "' in field '" # match.field # "' at chars " # debug_show(match.char_start, match.char_end)
    );

    Debug.print("This is the " # debug_show(match.token_pos) # "th token in the field value");
  };
};

Memory Optimizations and Performance Improvements

Document Store - Encoded Type Section Removal

ZenDB stores documents in the candid encoding format using the to_candid and from_candid native functions. Each encoded document includes a type section that describes the full schema of the document, which can be quite large for complex schemas with nested records. Since the type information is the same for all documents in a collection, ZenDB now stores it once in the collection metadata and strips it from each individual document, reducing the memory overhead of the main document store by up to 40% depending on the schema.

MemoryBTree Prefix Compression

The MemoryBTree now supports prefix key compression, where common key prefixes in a leaf node are stored once in the node’s metadata instead of per key. Only the suffixes that differ between keys are stored in the node’s key array. This is especially effective for indexes on fields with low cardinality, or increasing numeric values like timestamps, where adjacent keys often share long common prefixes. This optimization can reduce the memory usage of the B+Tree indexes by 11–20% in typical use cases, and even more in cases with long shared prefixes.

Index Comparison Performance

The index encoding (Orchid) was updated to an escape-based scheme from the previous length-prefixed encoding, such that each encoded index key can be sorted and compared directly in its encoded form without needing to decode it first.
This simplifies the comparison process as we can use the native Blob.compare function for B+Tree key comparisons, and in turn reduce the instruction count of index scans and lookups.

Had to split the post in 2 for the benchmarks section:

Benchmarks

The following benchmarks display the instruction count of various operations using the generational garbage collector. The tables below are collapsed by default; skip to Key Observations for a summary of the main takeaways.

BTree performance with Prefix Compression

After adding prefix compression, I benchmarked the MemoryBTree against other heap-based BTree implementations with 10,000 entries. The Memory B+Tree stores data in stable memory, so all the operations require one or more stable memory reads. Despite that, it holds up well overall and outperforms the heap-based btrees in some operations.

insert() get() replace() entries() remove()
RBTree 648_860_385 723_528_770 748_833_544 9_023_556 628_363_898
BTree 702_485_150 773_865_434 778_966_022 7_495_908 731_685_433
B+Tree 814_721_153 855_696_138 864_738_194 3_644_031 787_562_849
Memory B+Tree 696_921_876 495_443_755 653_778_978 82_245_195 928_614_765

ZenDB Performance

Testing the ZenDB Embedded Instance with a 1,000 icrc3 transaction records across three index configurations: no index, 7 single-field indexes, and 6 composite (fully-covered) indexes.

Index configurations

7 single-field indexes

# Field Direction
1 btype (Text) Ascending
2 tx.amt (Nat) Ascending
3 ts (timestamp: Nat) Ascending
4 tx.from.owner (Principal) Ascending
5 tx.to.owner (Principal) Ascending
6 tx.spender.owner (Principal) Ascending
7 fee (Nat) Ascending

6 composite (fully-covered) indexes

# Fields (in order) Direction
1 tx.amt Ascending
2 ts Ascending
3 btype & tx.amt Ascending
4 btype & ts Ascending
5 tx.from.owner & ts Ascending
6 tx.to.owner & ts Ascending
7 tx.spender.owner & ts Ascending
Full benchmark — unsorted (1k txs)
#stableMemory no index #stableMemory 7 single field indexes #stableMemory 6 fully covered indexes
insert with no index 233_823_865 233_926_731 233_808_454
create and populate indexes 2_258 902_588_652 1_136_530_982
clear collection entries and indexes 128_951 1_022_019 1_029_574
insert with indexes 245_505_162 1_176_136_665 1_427_948_317
query(): no filter (all txs) 96_469_310 96_466_384 96_336_040
query(): single field (btype = ‘1mint’) 176_922_894 19_607_601 19_722_483
query(): number range (250 < tx.amt <= 400) 187_495_947 14_599_870 14_599_799
query(): #And (btype=‘1burn’ AND tx.amt>=750) 171_185_233 35_576_207 5_817_771
query(): #And (500_000<ts<=1_000_000 AND 200<amt<=600) 189_349_994 77_015_710 76_804_309
query(): #Or (btype == ‘1xfer’ OR ‘2xfer’ OR ‘1mint’) 269_791_919 58_379_207 59_195_798
query(): #Or (btype == ‘1xfer’ OR tx.amt >= 500) 259_866_293 64_741_889 64_995_637
query(): #Or (btype == ‘1xfer’ OR tx.amt >= 500 OR ts > 500_000) 289_959_883 89_432_882 89_482_878
query(): #Or (500_000<ts<=1_000_000 OR 200<amt<=600) 388_411_148 77_834_740 77_676_776
query(): #Or (btype in [‘1xfer’, ‘1burn’] OR (tx.amt < 200 OR tx.amt >= 800)) 301_363_100 67_296_594 67_976_329
query() → principals[0] == tx.to.owner (is recipient) 195_905_828 1_417_817 1_433_868
query() → principals[0..10] == tx.to.owner (is recipient) 728_132_508 17_852_912 18_098_554
query() → all txs involving principals[0] 354_831_711 5_488_351 5_578_109
query() → all txs involving principals[0..10] 1_905_720_311 54_351_107 55_906_756
update(): single operation → #add amt += 100 553_763_772 842_750_540 1_179_919_839
update(): multiple independent operations → #add, #sub, #mul, #div on tx.amt 895_943_182 1_191_848_406 1_537_296_733
update(): multiple nested operations → #add, #sub, #mul, #div on tx.amt 614_730_530 902_610_219 1_245_071_566
update(): multiple operations on multiple fields → #add, #sub, #mul, #div on (tx.amt, ts, fee) 855_591_956 1_712_987_559 3_121_251_795
replace() → replace half the tx with new tx 406_870_899 2_322_223_939 2_699_176_978
delete() 248_187_105 1_102_175_566 1_354_143_185
Full benchmark — sorted by ts (1k txs)
#stableMemory no index (sorted by ts) #stableMemory 7 single field indexes (sorted by ts) #stableMemory 6 fully covered indexes (sorted by ts)
insert with no index 233_775_124 233_854_312 233_812_609
create and populate indexes 5_282 902_592_295 1_136_534_945
clear collection entries and indexes 131_975 1_025_540 1_033_598
insert with indexes 245_498_888 1_176_123_080 1_427_939_441
query(): no filter (all txs) 2_443_529_185 94_820_370 94_737_689
query(): single field (btype = ‘1mint’) 535_393_015 198_176_831 19_848_687
query(): number range (250 < tx.amt <= 400) 431_162_508 209_897_121 209_815_250
query(): #And (btype=‘1burn’ AND tx.amt>=750) 238_741_538 103_283_841 41_144_229
query(): #And (500_000<ts<=1_000_000 AND 200<amt<=600) 533_786_348 119_896_552 119_796_112
query(): #Or (btype == ‘1xfer’ OR ‘2xfer’ OR ‘1mint’) 1_732_296_041 598_482_354 81_219_135
query(): #Or (btype == ‘1xfer’ OR tx.amt >= 500) 1_712_862_492 435_351_839 262_543_986
query(): #Or (btype == ‘1xfer’ OR tx.amt >= 500 OR ts > 500_000) 2_332_627_596 471_169_974 298_470_138
query(): #Or (500_000<ts<=1_000_000 OR 200<amt<=600) 2_765_040_224 280_842_701 280_688_066
query(): #Or (btype in [‘1xfer’, ‘1burn’] OR (tx.amt < 200 OR tx.amt >= 800)) 1_782_204_805 814_661_966 468_870_974
query() → principals[0] == tx.to.owner (is recipient) 202_572_927 8_810_406 1_559_902
query() → principals[0..10] == tx.to.owner (is recipient) 968_350_238 370_860_261 27_979_488
query() → all txs involving principals[0] 396_009_863 50_288_933 7_159_165
query() → all txs involving principals[0..10] 2_644_443_913 1_325_573_308 79_015_306
update(): single operation → #add amt += 100 553_766_793 842_754_609 1_179_923_913
update(): multiple independent operations → #add, #sub, #mul, #div on tx.amt 895_945_939 1_191_929_547 1_537_366_300
update(): multiple nested operations → #add, #sub, #mul, #div on tx.amt 614_732_485 902_644_936 1_245_021_384
update(): multiple operations on multiple fields → #add, #sub, #mul, #div on (tx.amt, ts, fee) 855_619_401 1_713_231_335 3_120_995_066
replace() → replace half the tx with new tx 406_872_379 2_322_233_766 2_699_187_719
delete() 248_171_904 1_102_096_454 1_353_435_475

Key Observations

Sorting is expensive without indexes

Sorting without an index requires the engine to load all matching documents into memory and sort them there. Even with just 1,000 documents, this query costs orders of magnitude more than its indexed equivalent. For sorted queries, indexes are a functional requirement, not just a performance optimization.

Query (sorted by ts) No Index 7 Single-Field 6 Composite
query(): no filter (all txs) 2,443,529,185 94,820,370 94,737,689
query(): single field (btype = ‘1mint’) 535,393,015 198,176,831 19,848,687
query(): #Or (500_000<ts<=1_000_000 OR 200<amt<=600) 2,765,040,224 280,842,701 280,688,066

Indexes cost more on writes, but pay off on reads
Each index increases the per-insert cost, and composite indexes cost more than single-field indexes because they store multiple fields per entry. However, the read savings compound across every query that can use those indexes, so the upfront write cost is often worth it for the improved read performance. For example, the write cost of inserting with composite indexes is 50% less than the query cost on the non-indexed collection in the previous section.

Operation No Index 7 Single-Field 6 Composite
insert with indexes 245,505,162 1,176,136,665 1,427,948,317

Composite indexes perform best for queries on multiple fields

For queries that filter or sort on multiple fields, composite indexes that cover those fields can be significantly faster than single-field indexes. The engine can scan the composite index directly to find matching documents in the sorted order without needing to do additional filtering or in-memory sorting.

The following are query examples filtering one field and sorting on another. The composite index covering both fields is the fastest in all cases.

Query No Index 7 Single-Field 6 Composite
query(): single field (btype = ‘1mint’) (unsorted) 176,922,894 19,607,601 19,722,483
query(): single field (btype = ‘1mint’) (sorted by ts) 535,393,015 198,176,831 19,848,687
query(): #Or (btype == ‘1xfer’ OR ‘2xfer’ OR ‘1mint’) (sorted by ts) 1,732,296,041 598,482,354 81,219,135
query() → principals[0..10] == tx.to.owner (sorted by ts) 968,350,238 370,860,261 27,979,488

Update, Replace and Delete scale with index count

Just like inserts, the other write operations also cost more as the number of indexes grows. When changing fields in the document it is recommended to use update() instead of replace() since it only re-indexes the modified fields, instead of the entire document.

Operation No Index 7 Single-Field 6 Composite
update(): single operation (#add amt += 100) 553,763,772 842,750,540 1,179,919,839
update(): multiple independent ops on tx.amt 895,943,182 1,191,848,406 1,537,296,733
replace() → replace half the txs 406,870,899 2,322,223,939 2,699,176,978
delete() 248,187,105 1,102,175,566 1,354,143,185

#Or query cost grows with the number of branches; #And does not

#And queries are handled by scanning a single best-matching index once and applying any remaining conditions as an inline filter over the results iterator. The instruction count reflects one BTree scan regardless of how many conditions are combined.

#Or is fundamentally different: each condition requires its own BTree scan and all result iterators from the scans must be merged into one and deduplicated. Resulting the query cost growing with each additional OR condition.

Query (single-field indexes, unsorted) Instructions
query(): #And (btype=‘1burn’ AND tx.amt>=750) 35,576,207
query(): #Or (btype == ‘1xfer’ OR tx.amt >= 500) 64,741,889
query(): #Or (btype == ‘1xfer’ OR tx.amt >= 500 OR ts > 500_000) 89,432,882

ZenDB Memory Update

Measured against 10k icrc3 txs across the same 3 collection configurations.

Compression gains — 10k txs

Each cell shows bytes formatted as allocated (used). The savings are relative to uncompressed.

Collection / BTree Uncompressed Compressed Savings
no_index — total 2560.0 KB (2396.9 KB) 1600.0 KB (1481.3 KB) 37.5% (38.1%)
  └─ doc store 2560.0 KB (2396.9 KB) 1600.0 KB (1481.3 KB) 37.5% (38.1%)
single_field — total 7680.0 KB (6350.1 KB) 6144.0 KB (4952.0 KB) 20.0% (22.0%)
  └─ doc store 2560.0 KB (2396.9 KB) 1600.0 KB (1481.3 KB) 37.5% (38.1%)
  └─ indexes (total) 5120.0 KB (3953.1 KB) 4544.0 KB (3470.7 KB) 11.2% (12.2%)
    └─ sf_idx_0 [btype ↑, :id ↑] 832.0 KB (654.6 KB) 640.0 KB (456.6 KB) 23.0% (30.2%)
    └─ sf_idx_1 [tx.amt ↑, :id ↑] 832.0 KB (646.1 KB) 704.0 KB (596.7 KB) 15.3% (7.6%)
    └─ sf_idx_2 [ts ↑, :id ↑] 832.0 KB (646.1 KB) 768.0 KB (614.8 KB) 7.6% (4.8%)
    └─ sf_idx_3 [tx.from.owner ↑, :id ↑] 832.0 KB (686.3 KB) 832.0 KB (679.4 KB) 0% (1%)
    └─ sf_idx_4 [tx.to.owner ↑, :id ↑] 704.0 KB (504.8 KB) 704.0 KB (499.8 KB) 0% (1%)
    └─ sf_idx_5 [tx.spender.owner ↑, :id ↑] 320.0 KB (187.4 KB) 320.0 KB (181.8 KB) 0% (3%)
    └─ sf_idx_6 [fee ↑, :id ↑] 768.0 KB (627.5 KB) 576.0 KB (441.6 KB) 25.0% (29.6%)
fully_covered — total 9600.0 KB (8200.6 KB) 8128.0 KB (6975.3 KB) 15.3% (14.9%)
  └─ doc store 2560.0 KB (2396.9 KB) 1600.0 KB (1481.3 KB) 37.5% (38.1%)
  └─ indexes (total) 7040.0 KB (5803.7 KB) 6528.0 KB (5494.0 KB) 7.2% (5.3%)
    └─ fc_idx_0 [tx.amt ↑, :id ↑] 832.0 KB (646.1 KB) 704.0 KB (596.7 KB) 15.3% (7.6%)
    └─ fc_idx_1 [ts ↑, :id ↑] 832.0 KB (646.1 KB) 768.0 KB (614.8 KB) 7.6% (4.8%)
    └─ fc_idx_2 [btype ↑, tx.amt ↑, :id ↑] 896.0 KB (730.3 KB) 768.0 KB (626.6 KB) 14.2% (14.1%)
    └─ fc_idx_3 [btype ↑, ts ↑, :id ↑] 896.0 KB (730.3 KB) 768.0 KB (628.2 KB) 14.2% (13.9%)
    └─ fc_idx_4 [tx.from.owner ↑, btype ↑, ts ↑, :id ↑] 1152.0 KB (975.4 KB) 1152.0 KB (965.6 KB) 0% (1%)
    └─ fc_idx_5 [tx.to.owner ↑, btype ↑, ts ↑, :id ↑] 1088.0 KB (915.0 KB) 1024.0 KB (913.2 KB) 5.8% (0.1%)
    └─ fc_idx_6 [tx.from.owner ↑, tx.to.owner ↑, btype ↑, ts ↑, :id ↑] 1344.0 KB (1160.1 KB) 1344.0 KB (1148.5 KB) 0% (1%)
BTree metadata — uncompressed (10k txs, avg doc 228 B)
Config Entries Leaves Branches Allocated Used Free Key Bytes Val Bytes Metadata
no_index / docs 10,000 38 1 2560.0 KB 2396.9 KB 163.0 KB 263.7 KB 1970.6 KB 162.5 KB
single_field (total) - - - 7680.0 KB 6350.1 KB 1329.8 KB 2754.3 KB 2628.3 KB 967.3 KB
  └─ sf_idx_0 [btype ↑, :id ↑] 10,000 35 1 832.0 KB 654.6 KB 177.3 KB 387.0 KB 117.2 KB 150.3 KB
  └─ sf_idx_1 [tx.amt ↑, :id ↑] 10,000 32 1 832.0 KB 646.1 KB 185.8 KB 390.6 KB 117.2 KB 138.1 KB
  └─ sf_idx_2 [ts ↑, :id ↑] 10,000 32 1 832.0 KB 646.1 KB 185.8 KB 390.6 KB 117.2 KB 138.1 KB
  └─ sf_idx_3 [tx.from.owner ↑, :id ↑] 8,033 24 1 832.0 KB 686.3 KB 145.6 KB 486.4 KB 94.1 KB 105.6 KB
  └─ sf_idx_4 [tx.to.owner ↑, :id ↑] 5,972 16 1 704.0 KB 504.8 KB 199.1 KB 361.6 KB 70.0 KB 73.1 KB
  └─ sf_idx_5 [tx.spender.owner ↑, :id ↑] 2,086 7 1 320.0 KB 187.4 KB 132.5 KB 126.3 KB 24.5 KB 36.6 KB
  └─ sf_idx_6 [fee ↑, :id ↑] 10,000 38 1 768.0 KB 627.5 KB 140.4 KB 347.7 KB 117.2 KB 162.5 KB
fully_covered (total) - - - 9600.0 KB 8200.6 KB 1399.3 KB 4279.3 KB 2791.3 KB 1129.8 KB
  └─ fc_idx_0 [tx.amt ↑, :id ↑] 10,000 32 1 832.0 KB 646.1 KB 185.8 KB 390.6 KB 117.2 KB 138.1 KB
  └─ fc_idx_1 [ts ↑, :id ↑] 10,000 32 1 832.0 KB 646.1 KB 185.8 KB 390.6 KB 117.2 KB 138.1 KB
  └─ fc_idx_2 [btype ↑, tx.amt ↑, :id ↑] 10,000 32 1 896.0 KB 730.3 KB 165.6 KB 474.9 KB 117.2 KB 138.1 KB
  └─ fc_idx_3 [btype ↑, ts ↑, :id ↑] 10,000 32 1 896.0 KB 730.3 KB 165.6 KB 474.9 KB 117.2 KB 138.1 KB
  └─ fc_idx_4 [tx.from.owner ↑, btype ↑, ts ↑, :id ↑] 10,000 32 1 1152.0 KB 975.4 KB 176.5 KB 720.0 KB 117.2 KB 138.1 KB
  └─ fc_idx_5 [tx.to.owner ↑, btype ↑, ts ↑, :id ↑] 10,000 32 1 1088.0 KB 915.0 KB 172.9 KB 659.6 KB 117.2 KB 138.1 KB
  └─ fc_idx_6 [tx.from.owner ↑, tx.to.owner ↑, btype ↑, ts ↑, :id ↑] 10,000 32 1 1344.0 KB 1160.1 KB 183.8 KB 904.7 KB 117.2 KB 138.1 KB
BTree metadata — compressed (10k txs, avg doc 140 B)
Config Entries Leaves Branches Allocated Used Free Key Bytes Val Bytes Metadata
no_index / docs 10,000 52 1 1600.0 KB 1481.3 KB 118.6 KB 170.7 KB 1199.1 KB 111.4 KB
single_field (total) 66,091 357 8 6144.0 KB 4952.0 KB 1192.0 KB 2325.6 KB 1856.9 KB 769.8 KB
  └─ sf_idx_0 [btype ↑, :id ↑] 10,000 60 1 640.0 KB 456.6 KB 183.3 KB 211.4 KB 117.2 KB 127.9 KB
  └─ sf_idx_1 [tx.amt ↑, :id ↑] 10,000 50 1 704.0 KB 596.7 KB 107.2 KB 372.2 KB 117.2 KB 107.3 KB
  └─ sf_idx_2 [ts ↑, :id ↑] 10,000 53 1 768.0 KB 614.8 KB 153.1 KB 384.0 KB 117.2 KB 113.4 KB
  └─ sf_idx_3 [tx.from.owner ↑, :id ↑] 8,033 41 1 832.0 KB 679.4 KB 152.6 KB 496.6 KB 94.1 KB 88.7 KB
  └─ sf_idx_4 [tx.to.owner ↑, :id ↑] 5,972 32 1 704.0 KB 499.8 KB 204.2 KB 359.7 KB 70.0 KB 70.1 KB
  └─ sf_idx_5 [tx.spender.owner ↑, :id ↑] 2,086 11 1 320.0 KB 181.8 KB 138.2 KB 130.5 KB 24.5 KB 26.8 KB
  └─ sf_idx_6 [fee ↑, :id ↑] 10,000 58 1 576.0 KB 441.6 KB 134.3 KB 200.5 KB 117.2 KB 123.8 KB
fully_covered (total) 80,000 453 8 8128.0 KB 6975.3 KB 1152.7 KB 3987.8 KB 2019.8 KB 967.8 KB
  └─ fc_idx_0 [tx.amt ↑, :id ↑] 10,000 50 1 704.0 KB 596.7 KB 107.2 KB 372.2 KB 117.2 KB 107.3 KB
  └─ fc_idx_1 [ts ↑, :id ↑] 10,000 53 1 768.0 KB 614.8 KB 153.1 KB 384.0 KB 117.2 KB 113.4 KB
  └─ fc_idx_2 [btype ↑, tx.amt ↑, :id ↑] 10,000 58 1 768.0 KB 626.6 KB 141.3 KB 385.6 KB 117.2 KB 123.8 KB
  └─ fc_idx_3 [btype ↑, ts ↑, :id ↑] 10,000 54 1 768.0 KB 628.2 KB 139.7 KB 395.4 KB 117.2 KB 115.5 KB
  └─ fc_idx_4 [tx.from.owner ↑, btype ↑, ts ↑, :id ↑] 10,000 62 1 1152.0 KB 965.6 KB 186.4 KB 716.4 KB 117.2 KB 132.0 KB
  └─ fc_idx_5 [tx.to.owner ↑, btype ↑, ts ↑, :id ↑] 10,000 62 1 1024.0 KB 913.2 KB 110.7 KB 663.9 KB 117.2 KB 132.0 KB
  └─ fc_idx_6 [tx.from.owner ↑, tx.to.owner ↑, btype ↑, ts ↑, :id ↑] 10,000 62 1 1344.0 KB 1148.5 KB 195.5 KB 899.3 KB 117.2 KB 132.0 KB

Key Observations

Doc store compression: Prefix compression reduces key data by 35% (263.7 KB → 170.7 KB). Stripping the candid type reduces value data by 39% (1970.6 KB → 1199.1 KB). Total savings: 38.1% for the document store.

Compression payoff depends on how much keys repeat. Fields like btype and fee have few distinct values, so their keys repeat often and compress well, saving 23–30%. Principal-based indexes compress minimally (0–3%) since each principal is essentially a random set of bytes with little shared prefixes. This results in modest savings of 11–12% across all indexes combined.


ZenDB is still early but the foundation is in a good place, and there’s more to come in terms of memory savings, performance, and additional features like backups and migrations. Check out the GitHub readme for the full roadmap. Thanks for reading! As always, bug reports, feature requests, and contributions are very welcome.