Home | Send Feedback | Share on Bluesky |

Xodus, an embedded database running in the JVM

Published: 29. July 2025  •  java

Xodus is a high-performance, embedded database developed by JetBrains. The database is written in Java and Kotlin. Xodus operates entirely in-process, meaning it runs within your application’s JVM without requiring a separate server or complex setup. This also means that only one process can access the database at a time.

The database is licensed under the Apache 2.0 license, it is schema-less, transactional, full ACID compliance with snapshot isolation, and optimized for high performance. Xodus provides a low-level key-value store API, an object-oriented Entity Store API, and a Virtual File System (VFS) API for hierarchical file-like data storage.

The source code of Xodus is hosted on GitHub: https://github.com/JetBrains/xodus

In this blog post we will explore some of the key features of Xodus and how to use them in Java.

Xodus Architecture

Schema-Less Design
Xodus doesn't enforce rigid schemas. You can store data dynamically, making it ideal for applications with evolving or unpredictable data structures.

Snapshot Isolation
Every transaction in Xodus operates at the snapshot isolation level. This ensures:

Append-Only Storage
Xodus uses an append-only storage mechanism:

Garbage Collection
Old data versions are automatically cleaned up by a background garbage collector, ensuring storage efficiency without manual intervention.

Embedded and Serverless
Zero configuration: Add the dependency and start coding.
No network latency: Runs in the same JVM as your application.
Portable: Database files are cross-platform.


API Layers

Xodus provides three API layers:

Environments
The foundation of Xodus. Manages transactions, storage, and garbage collection. Provides a key-value interface for raw data storage.

Entity Stores
Built on top of Environments.
Offers an object-oriented model with:

Virtual File Systems (VFS)
Built on top of Environments.
An API for storing hierarchical file-like data within the database.

Environment API

The Environment API forms the core of Xodus, providing a low-level key-value store interface.

To work with the Environment API, you need to add the following dependency to your project:

    <dependency>
      <groupId>org.jetbrains.xodus</groupId>
      <artifactId>xodus-environment</artifactId>
      <version>2.0.1</version>
    </dependency>

pom.xml

Then you can create an Environment instance, which represents a database. As the first argument, you pass the path to the database directory.

Inside an environment, data is stored in Stores, which are similar to tables in relational databases. An environment can contain multiple stores, and each store holds multiple key-value pairs.

The Environment API can only store ByteIterable instances, a Xodus class that mixes byte iterable and array. For convenience the library provides bindings to convert Java types to ByteIterable and vice versa. For example: BooleanBinding, IntegerBinding, FloatBinding, StringBinding and many more.

    try (Environment env = Environments.newInstance("./data")) {
      // insert
      env.executeInTransaction(txn -> {
        Store store = env.openStore("Users", StoreConfig.WITHOUT_DUPLICATES, txn);
        store.put(txn, StringBinding.stringToEntry("user1"),
            StringBinding.stringToEntry("Alice"));
      });

      // update
      env.executeInTransaction(txn -> {
        Store store = env.openStore("Users", StoreConfig.WITHOUT_DUPLICATES, txn);
        store.put(txn, StringBinding.stringToEntry("user1"),
            StringBinding.stringToEntry("Alice Smith"));
      });

      // read
      env.executeInReadonlyTransaction(txn -> {
        Store store = env.openStore("Users", StoreConfig.WITHOUT_DUPLICATES, txn);
        ByteIterable value = store.get(txn, StringBinding.stringToEntry("user1"));
        System.out.println("User1: " + StringBinding.entryToString(value));
      });

      // delete
      env.executeInTransaction(txn -> {
        Store store = env.openStore("Users", StoreConfig.WITHOUT_DUPLICATES, txn);
        store.delete(txn, StringBinding.stringToEntry("user1"));
      });

EnvironmentDemo.java

An application can also pass an EnvironmentConfig instance when opening an environment. With this config you can control various aspects of the environment.

Here is an example that opens the environment in read-only mode:

  File dbDir = new File("./data");
  EnvironmentConfig config = new EnvironmentConfig();
  config.setEnvIsReadonly(true);
  try (Environment env = Environments.newInstance(dbDir, config)) {
    // ...
  }

It is important to close the environment when you are done with it, either with the close method or by using a try-with-resources block.


Transactions

Every database operation must run within a transaction. Xodus supports both read-only and read-write transactions. An application can start multiple transactions concurrently. If you want to make sure that no other transaction can modify the data you are working with, you can use an exclusive transaction. Read-only transactions are still allowed when an exclusive transaction is running. Xodus throws an exception if an application tries to modify data in a read-only transaction.


The Environment API also provides computeIn* methods that run a block of code in a transaction and return a result.

  try (Environment env = Environments.newInstance("./data")) {
     String result = env.computeInReadonlyTransaction(txn -> {
     Store store = env.openStore("Users", StoreConfig.WITHOUT_DUPLICATES, txn);
       var value = store.get(txn, StringBinding.stringToEntry("user1"));
       if (value != null) {
         return StringBinding.entryToString(value);
       } else {
         return null;
       }
    });
  }

Another way to handle the transaction is to start one with a env.begin* method and then commit or abort it manually. Changes made in the current transaction, can be undone by calling the revert method on the transaction instance.

This example starts a readonly transaction with beginReadonlyTransaction and shows you how to check if a store exists and list all stores in an environment:

    try (Environment env = Environments.newInstance("./data")) {
      Transaction txn = env.beginReadonlyTransaction();
      try {
        // Check if store exists
        boolean storeExists = env.storeExists("Users", txn);
        System.out.println("Store 'Users' exists: " + storeExists);

        // List all stores
        List<String> storeNames = env.getAllStoreNames(txn);
        System.out.println("Stores in the environment:");
        for (String name : storeNames) {
          System.out.println("- " + name);
        }
      }
      finally {
        txn.abort(); // Readonly transaction does not need to be committed
      }
    }

EnvironmentDemo.java


Another way to handle transactions is to use a ContextualEnvironment. This kind of environment internally uses a ThreadLocal variable to keep track of the current transaction. This simplifies the code because you do not have to pass the transaction instance into every method call. You still need to start and commit transactions either with env.begin* or run the code in a env.executeIn* or env.computeIn* block.

    try (ContextualEnvironment env = Environments.newContextualInstance("./data")) {
      try {
        env.beginTransaction();
        ContextualStore store = env.openStore("Users", StoreConfig.WITHOUT_DUPLICATES);
        store.put(StringBinding.stringToEntry("user1"),
            StringBinding.stringToEntry("Alice"));
      }
      finally {
        env.getCurrentTransaction().commit();
      }
    }

EnvironmentDemo.java


Store config

In the examples above you see that the code passes StoreConfig.WITHOUT_DUPLICATES as argument when opening a store. This specifies that this store does not allow duplicate keys. Xodus supports several configurations for stores, which control how keys are handled regarding duplicates and prefixing.

Here is a summary of the available store configurations:

WITHOUT_DUPLICATES**

Description:
The store does not allow duplicate keys, and key prefixing is not supported.

Behavior:
Each key must be unique. Attempting to insert a duplicate key will either overwrite the existing entry or fail, depending on the operation.

Use Case:
Ideal for simple key-value stores where keys are inherently unique (e.g., user IDs, session tokens). When you don't need prefix-based queries (e.g., finding all keys starting with "user_").


WITH_DUPLICATES

Description:
The store allows duplicate keys, and key prefixing is not supported.

Behavior:
Multiple entries can share the same key. For example, you could store several values for the key "order_123". Retrieving a key returns all associated values (or an iterator over them).

Use Case:
Useful for logging or time-series data where multiple entries might share the same key (e.g., timestamps for the same event). When you don’t need prefix-based queries but require flexibility in key-value mapping.


WITHOUT_DUPLICATES_WITH_PREFIXING

Description:
The store does not allow duplicate keys, but key prefixing is supported.

Behavior:
Keys must be unique, but the store optimizes for prefix-based operations. Efficiently supports range queries (e.g., find all keys starting with "user_").

Use Case:
Hierarchical or categorized data (e.g., "user:1:name", "user:1:email"). When you need to perform range queries or group keys by a common prefix (e.g., retrieving all keys for a specific user).


WITH_DUPLICATES_WITH_PREFIXING

Description:
The store allows duplicate keys, and key prefixing is supported.

Behavior:
Combines the flexibility of duplicate keys with prefix-based optimizations. Supports both multiple values per key and efficient prefix queries.

Use Case:
Complex scenarios like multi-tenant systems where keys might be duplicated across tenants (e.g., "tenant_A:order_123", "tenant_B:order_123"), but you still need prefix-based access (e.g., all orders for "tenant_A"). Logging or event systems with duplicate keys and hierarchical organization.


USE_EXISTING

Description:
Opens an existing store without needing to know its original configuration (duplicates or prefixing).

Behavior:
The store must already exist; attempting to open a non-existent store will fail. The runtime configuration (duplicates/prefixing) is inferred from the store's metadata. No new store is created.

Use Case:
When accessing a store created earlier, but you don't want to (or can't) specify its original settings. Useful in scenarios where the store's configuration is managed externally or dynamically.


Store Operations

The store provides a set of operations to store data in a store, update it, retrieve it, and delete it. Depending on the store configuration, the behavior of operations like put, get, and delete varies:

put
Puts a key/value pair into the store and returns a boolean as result.

For stores with key duplicates, it returns true if the pair didn't exist in the store. It inserts the pair if the key doesn't exist or the key exists, but all the values associated with the key are different from the new value.

For stores without key duplicates, it returns true if the key didn't exist or the new value differs from the existing one. It overwrites the existing value if the key already exists.

putRight
This method is very similar to the put method. It can be used if it is certain that the key is definitely greater than any other key in the store. In that case, no search is being done before insertion, so putRight can perform several times faster than put. For example, you use a timestamp as a key, and you know that the application never inserts the same timestamp twice.

add
Adds a key/value pair to the store if the key doesn't exist. This method behaves the same with and without key duplicates. It returns true if the key doesn't exist, meaning it adds the pair to the store. If the key already exists, it does nothing and returns false. So even in a store that allows duplicates, this method will not add a new value for an existing key.

get
This method retrieves a value by a key. For stores without key duplicates, it returns the value associated with the key if the key exists, otherwise it returns null.
For stores with key duplicates, it returns the smallest not-null value associated with the key or null if the key doesn't exist. This means that if there are multiple values for a key, get will return the first one in the natural order of keys.

exists
This method checks if a key/value pair exists in the store. It returns true if the key exists in the store, regardless of whether the store allows duplicates or not. If the key does not exist, it returns false.

count
This method returns the total number of key/value pairs in the store. For stores without key duplicates, it returns the number of unique keys. For stores with key duplicates, it returns the total number of key/value pairs, including duplicates.

delete
This method deletes a key/value pair by a key. For stores without key duplicates, it deletes a single key/value pair and returns true if the pair was deleted. If the key does not exist, it returns false.
For stores with key duplicates, it deletes all pairs with the given key and returns true if any were deleted. If no pairs were deleted, it returns false. You cannot delete a particular key/value pair with this method, you have to use a cursor for this purpose.

openCursor
This method returns a Cursor instance that allows an application to access key/value pairs in the store in both ascending and descending order and to perform random access operations.


Cursor

To open a cursor, an application calls the openCursor method on a store instance. The cursor can then be used to iterate over key/value pairs in the store. It's important to close the cursor after use to release resources. The cursor has access to the current key and value, and it can move through the store in both directions. In this example the cursor moves forward with getNext and retrieves the current key and value:

try (Cursor cursor = store.openCursor(txn)) {
   while (cursor.getNext()) {
     cursor.getKey();   // current key
     cursor.getValue(); // current value
   }
}

A newly opened cursor always points to a "virtual" key/value pair before the first (leftmost) pair in the store. The application then needs to move the cursor first before reading the current key and value. A cursor can be moved to the first key/value pair with getNext, to the last pair with getLast, or to a specific key with getSearchKey. Continuously calling getNext will move the cursor to the next key/value pair in ascending order, while getPrev will move it to the previous pair in descending order.

For stores that allow key duplicates, additional methods are available to iterate over all values for a specific key. getNextDup moves the cursor to the next key/value pair with the same key, while getNextNoDup moves it to the next pair with a different key. The same applies for the previous direction with getPrevDup and getPrevNoDup.

All get* methods return a boolean indicating whether the operation was successful or not. If there are no more pairs in the store, the cursor will return false.


A cursor can also be used to search for a specific key or key/value pair.

The getSearchKey method moves the cursor to the first (leftmost) value associated with the specified key. If the key exists, it returns the value associated with that key.
In stores that allow key duplicates, the cursor will be moved to the first (leftmost) key/value pair for the specified key, and it returns the associated value. To list all values for a key, you can use the getNextDup method after calling getSearchKey. getSearchKey returns null if it cannot find the specified key.

The getSearchKeyRange method moves the cursor to the first pair whose key is equal to or greater than the specified key. It returns the value of this pair if it exists, or null if nothing is found.

To find a pair with a specific key and value, you can use the getSearchBoth method. This method moves the cursor to the key/value pair with the specified key and value, returning true if the pair exists, otherwise false.

The getSearchBothRange method is similar to getSearchKeyRange, but it searches for a key/value pair where the key matches the specified key and the value is equal to or greater than the specified value. It returns the value of this pair if it exists, or null if nothing is found.

All the search methods do not move the cursor if they cannot find the specified key or key/value pair.


The cursor can also delete entries with the deleteCurrent method. This method deletes the key/value pair that the cursor is currently positioned on and returns true. If the cursor is not positioned on a valid key/value pair, it returns false.


Examples

Here is an example with two stores, one that allows duplicates and one that does not. We see in this example that calling get on a store that allows duplicates only returns the first value. To read all values for a key, we need to use a cursor. The code moves the cursor with getSearchKey to the first value for the key and then iterates over all values with getNextDup.

    File dbDir = new File("./demo2");
    try (Environment env = Environments.newInstance(dbDir)) {
      Transaction rwTxn = env.beginTransaction();
      Store userStore = env.openStore("User", StoreConfig.WITHOUT_DUPLICATES, rwTxn);
      Store permissionsStore = env.openStore("Permissions", StoreConfig.WITH_DUPLICATES,
          rwTxn);

      userStore.put(rwTxn, LongBinding.longToEntry(1),
          StringBinding.stringToEntry("admin"));
      userStore.put(rwTxn, LongBinding.longToEntry(2),
          StringBinding.stringToEntry("user"));

      permissionsStore.put(rwTxn, LongBinding.longToEntry(1),
          StringBinding.stringToEntry("read"));
      permissionsStore.put(rwTxn, LongBinding.longToEntry(1),
          StringBinding.stringToEntry("write"));
      permissionsStore.put(rwTxn, LongBinding.longToEntry(2),
          StringBinding.stringToEntry("read"));
      rwTxn.commit();

      env.executeInReadonlyTransaction(txn -> {

        String adminUser = StringBinding
            .entryToString(userStore.get(txn, LongBinding.longToEntry(1)));
        System.out.println(adminUser); // admin

        String permission = StringBinding
            .entryToString(permissionsStore.get(txn, LongBinding.longToEntry(1)));
        System.out.println(permission); // read

        try (Cursor cursor = permissionsStore.openCursor(txn)) {
          ByteIterable bi = cursor.getSearchKey(LongBinding.longToEntry(1));
          if (bi != null) {
            System.out.println(StringBinding.entryToString(bi));
            while (cursor.getNextDup()) {
              bi = cursor.getValue();
              System.out.println(StringBinding.entryToString(bi)); // first read then
                                                                   // write
            }
          }
        }

      });

    }

EnvironmentDuplicateDemo.java


Here is an example with key prefixing. The example inserts sensor data with prefixes "sensor1:" and "sensor2:". It then uses a cursor to retrieve all values for the prefix "sensor1:". The getSearchKeyRange method is used to find the first key that matches the prefix, and then it iterates over all matching keys. Note that the cursor method getNext does not stop when it reaches a different prefix, this method will continue until it reaches the end of the store. Therefore, this code checks if the key no longer starts with the specific prefix.

    String sensor1Prefix = "sensor1:";
    String sensor2Prefix = "sensor2:";

    File dbDir = new File("./demo3");
    try (Environment env = Environments.newInstance(dbDir)) {
      Transaction rwTxn = env.beginTransaction();
      try {
        Store sensorStore = env.openStore("Sensor",
            StoreConfig.WITHOUT_DUPLICATES_WITH_PREFIXING, rwTxn);

        for (int i = 0; i < 10; i++) {
          sensorStore.put(rwTxn, StringBinding.stringToEntry(sensor1Prefix + i),
              IntegerBinding.intToEntry((int) (Math.random() * 100)));
          sensorStore.put(rwTxn, StringBinding.stringToEntry(sensor2Prefix + i),
              IntegerBinding.intToEntry((int) (Math.random() * 100)));
        }
      }
      finally {
        rwTxn.commit();
      }

      env.executeInReadonlyTransaction(txn -> {
        Store sensorStore = env.openStore("Sensor", StoreConfig.USE_EXISTING, txn);

        try (Cursor cursor = sensorStore.openCursor(txn)) {
          ByteIterable value = cursor
              .getSearchKeyRange(StringBinding.stringToEntry(sensor1Prefix));
          if (value != null) {
            System.out.println(StringBinding.entryToString(cursor.getKey()));
            System.out.println(IntegerBinding.entryToInt(value));
            while (cursor.getNext()) {
              String key = StringBinding.entryToString(cursor.getKey());
              if (!key.startsWith(sensor1Prefix)) {
                break; // stop if we reach a different prefix
              }
              value = cursor.getValue();
              System.out.println(key);
              System.out.println(IntegerBinding.entryToInt(value));
            }
          }
        }

      });

    }

EnvironmentPrefixingDemo.java

Entity Stores

Entity Stores build on top of Environments, providing a higher-level, object-oriented API. You can define entities with properties and links, making it easy to model complex relationships.

The programming model looks similar to the Environment API, but instead of key-value pairs, you work with entities that have types and properties.

To use the Entity Store API, you need to add the following dependency to your project:

    <dependency>
      <groupId>org.jetbrains.xodus</groupId>
      <artifactId>xodus-entity-store</artifactId>
      <version>2.0.1</version>
    </dependency>

pom.xml

The main entry point of the Entity Store API is the PersistentEntityStore class. An application instantiates this class by passing the path to the database directory. It is important to close the store to release resources either with calling the close method or by using a try-with-resources block.

    File dbDir = new File("my_entity_store_db");
    try (PersistentEntityStore entityStore = PersistentEntityStores.newInstance(dbDir)) {

EntityStoreDemo.java

Transaction handling works exactly the same as with the Environment API. The PersistentEntityStore class provides methods like executeIn*, computeIn* and begin* methods to handle transactions. Like ContextualEnvironment, PersistentEntityStore is always aware of the transaction started in the current thread.


Store

Here is an example that stores users, posts, and comments into an entity store. The newEntity method in the transaction creates a new entity. The string passed as an argument is the entity type, which is similar to a table name in a relational database. The setProperty method sets a property on the entity, while the setLink method creates a link to another entity. The addLink method adds a link to an entity. You can also store binary data in an entity with the setBlob method.

    File dbDir = new File("my_entity_store_db");
    try (PersistentEntityStore entityStore = PersistentEntityStores.newInstance(dbDir)) {

      EntityId user1EntityId = entityStore.computeInTransaction(txn -> {
        Entity user1 = txn.newEntity("User");
        user1.setProperty("userId", 1);
        user1.setProperty("username", "john_doe");
        user1.setProperty("age", 25);
        user1.setProperty("email", "john.doe@example.com");
        user1.setProperty("active", true);
        user1.setProperty("created", System.currentTimeMillis());
        // user1.setBlob("image", new File(...));

        Entity user2 = txn.newEntity("User");
        user2.setProperty("userId", 2);
        user2.setProperty("username", "jane_doe");
        user2.setProperty("age", 42);
        user2.setProperty("email", "jane.doe@example.com");
        user2.setProperty("active", false);
        user2.setProperty("created", System.currentTimeMillis());

        Entity post1 = txn.newEntity("Post");
        post1.setProperty("postId", txn.getSequence("postsSequence").increment());
        post1.setProperty("title", "Hello World!");
        post1.setLink("author", user1);

        user1.addLink("posts", post1);

        Entity comment1 = txn.newEntity("Comment");
        comment1.setProperty("commentId",
            txn.getSequence("commentsSequence").increment());
        comment1.setProperty("text", "Great post!");
        comment1.setLink("post", post1);
        comment1.setLink("author", user2);

        user2.addLink("comments", comment1);

        return user1.getId();
      });

EntityStoreDemo.java

Each entity has a type and a unique id. The entity ID can be used to load the entity with the getEntity method.

String type = user1.getType();
EntityId id = user1.getId();

Entity user = txn.getEntity(id);

Sequences

The Entity Store API supports sequence generators. These are useful for generating unique IDs for entities.

A sequence generates unique, successive non-negative long number. Sequences are named, and you can request a sequence by name using the getSequence(String) or getSequence(String, long) methods on the transaction instance.

increment increments the sequence and returns the value. A new sequence always starts with 0, but you can specify an initial value when creating the sequence. The get method returns the current value of the sequence without incrementing it. A newly created sequence without initial value where increment has not been called yet returns -1. A sequence also provides the set method to set the current value of the sequence.

Sequences are persistent and will be stored when the surrounding transaction is committed.


Query

The Entity Store API provides several methods to query, sort, and filter entities. All these methods return an EntityIterable instance, which is a lazy iterable over entities. This means that the query is not executed until you iterate over the EntityIterable. The EntityIterable provides a getFirst and getLast method to retrieve the first and last entity in the iterable. There is also a reverse method to reverse the order of the entities in the iterable.


Retrieve all entities

To retrieve all entities of a specific type, you can use the getAll method. The method expects the entity type as arguments and returns an iterable over all entities of that type.

      entityStore.executeInReadonlyTransaction(txn -> {
        System.out.println("All users:");
        EntityIterable allUsers = txn.getAll("User");
        for (Entity entity : allUsers) {
          System.out.println("User: " + entity.getProperty("username"));
        }

EntityStoreDemo.java


Sort

You can also use the sort method to retrieve all entities of a specific type. This method returns the entities in the specified order. The first argument is the entity type, the second argument is the property name to sort by, and the third argument is a boolean indicating whether to sort in ascending order (true) or descending order (false).

Return all entities sorted by age property descending.

        EntityIterable allUsersSorted = txn.sort("User", "age", false);
        for (Entity entity : allUsersSorted) {
          System.out.println("User: " + entity.getProperty("username"));
        }

EntityStoreDemo.java

You can also combine multiple sort criteria by nesting the sort method calls. The outer sort method sorts by the first property, and the inner sort method sorts by the second property and so on.

Return all entities first sorted by age property ascending and then by username property descending.

        allUsersSorted = txn.sort("User", "age", txn.sort("User", "username", false),
            true);
        for (Entity entity : allUsersSorted) {
          System.out.println("User: " + entity.getProperty("username"));
        }

EntityStoreDemo.java


Count

To find out how many entities are in an EntityIterable, you can call the size method. This code returns the number of "User" entities in the store:

        EntityIterable users = txn.getAll("User");
        long exactCount = users.size();

EntityStoreDemo.java

The problem with the size method is, while it is faster than an iteration, it can still be slow. Xodus does a lot of caching internally, so sometimes the size is cached and can be returned immediately. You can check if the size is cached by using the count method, which always returns a value immediately. If the size is cached, it returns that, otherwise it returns -1.

You can combine these two methods to get an accurate count while still benefiting from caching:

      entityStore.executeInReadonlyTransaction(txn -> {
        EntityIterable allUsers = txn.getAll("User");

        // Fast check if cached
        long count = allUsers.count();
        if (count >= 0) {
          System.out.println("User count (cached): " + count);
        }
        else {
          // Slower
          long size = allUsers.size();
          System.out.println("User count: " + size);
        }

        // Check if empty
        if (allUsers.isEmpty()) {
          System.out.println("No users found");
        }
      });

EntityStoreDemo.java

The isEmpty method is the fastest way to check if the iterable contains any entities. In most cases, it is faster than size, and returns immediately if the result is cached.


Find

The queries above always returned all entities of a specific type. If you need to find specific entities, you can use one of the find methods.

One usage of the find method is to search for entities by a specific property value.

Finding a user by username. The find method takes the entity type, the property name, and the value to compare with as arguments, and returns an EntityIterable containing all entities that match the criteria.

      entityStore.executeInReadonlyTransaction(txn -> {
        Entity johnUser = txn.find("User", "username", "JOHN_DOE").getFirst();
        if (johnUser != null) {
          System.out.println("Found user: " + johnUser.getProperty("email"));
        }
      });

EntityStoreDemo.java

Note that search over string properties is case-insensitive. This code will find the user with the username "john_doe".


Another usage of the find method are range queries. You can search for entities with property values in a specific range. The find method takes the entity type, the property name, and the lower and upper bounds of the range as arguments. Both bounds are inclusive.

Find all users with age >= 20 and <= 30.

        EntityIterable youngUsers = txn.find("User", "age", 20, 30);

EntityStoreDemo.java


For string properties you can use the findStartingWith method to find entities with property values that start with a specific prefix and findContaining to find entities with property values that contain a specific substring.

        EntityIterable johnUsers = txn.findStartingWith("User", "username", "JOHN");

EntityStoreDemo.java

This method is case-insensitive and will find all users with usernames starting with "john", "John", "JOHN", etc.

        EntityIterable usersContainingDoe = txn.findContaining("User", "username", "DOE",
            true);

EntityStoreDemo.java

The last argument of the findStartingWith method is a boolean indicating whether the search should be case-insensitive. If set to true, the search will ignore case differences.


Another find variant are the findWith* methods. These methods are useful if your entities can have optional properties, blobs, or links, and you want to find entities that have or do not have these properties.

Assuming the age property is optional, you can find users with and without the age property like this:

        EntityIterable usersWithAge = txn.findWithProp("User", "age");
        System.out.println("Number of users with age property: " + usersWithAge.size());

        EntityIterable usersWithoutAge = txn.getAll("User")
            .minus(txn.findWithProp("User", "age"));
        System.out
            .println("Number of users without age property: " + usersWithoutAge.size());

EntityStoreDemo.java

The findWithBlob and findWithLinks methods work similarly, but they are used to find entities that have a specific blob property or a link to another entity.

        EntityIterable usersWithImage = txn.findWithBlob("User", "image");
        System.out.println("Users with image blob: " + usersWithImage.size());

        EntityIterable usersWithPosts = txn.findWithLinks("User", "posts");
        System.out.println("Users with posts links: " + usersWithPosts.size());

EntityStoreDemo.java


The find methods can be combined using a binary operation like union, intersect, or minus.

      entityStore.executeInReadonlyTransaction(txn -> {
        // intersect: AND
        EntityIterable users = txn.findStartingWith("User", "username", "john")
            .intersect(txn.findStartingWith("User", "email", "john.doe@"));
        System.out.println(
            "Users with username starting with 'john' AND email starting with 'john.doe@':");
        for (Entity entity : users) {
          System.out.println("User: " + entity.getProperty("username"));
        }

        // union: OR
        EntityIterable users2 = txn.findStartingWith("User", "username", "john")
            .union(txn.findStartingWith("User", "email", "john.doe@").distinct());
        System.out.println(
            "Users with username starting with 'john' OR email starting with 'john.doe@':");
        for (Entity entity : users2) {
          System.out.println("User: " + entity.getProperty("username"));
        }

        // minus: AND NOT
        EntityIterable users3 = txn.findStartingWith("User", "username", "john")
            .minus(txn.findStartingWith("User", "email", "john.doe@"));
        System.out.println(
            "Users with username starting with 'john' AND NOT email starting with 'john.doe@':");
        for (Entity entity : users3) {
          System.out.println("User: " + entity.getProperty("username"));
        }

      });

EntityStoreDemo.java

The find methods also provide the distinct method to remove duplicate entities from the iterable. Especially useful when you do an OR operation with union that might return the same entity multiple times.


The getLinks on the entity instance returns an iterable over outgoing links to another entity. This code returns all posts written by a specific user.

        Entity johnUser = txn.find("User", "username", "john_doe").getFirst();
        if (johnUser != null) {
          EntityIterable userPosts = johnUser.getLinks("posts");
          for (Entity post : userPosts) {
            System.out.println("Post: " + post.getProperty("title"));
          }
        }

EntityStoreDemo.java

The findLinks method can query incoming links to an entity. In this example retrieve all comments that are written by a specific user.

        Entity janeUser = txn.find("User", "username", "jane_doe").getFirst();
        if (janeUser != null) {
          EntityIterable postComments = txn.findLinks("Comment", janeUser, "author");
          for (Entity comment : postComments) {
            System.out.println("Comment: " + comment.getProperty("text"));
          }
        }

EntityStoreDemo.java


The selectManyDistinct and selectDistinct methods allows you to query entities based on their links. This example retrieves all posts written by users younger than 31 years old.

        EntityIterable users = txn.find("User", "age", 0, 30);
        EntityIterable posts = users.selectManyDistinct("posts");

EntityStoreDemo.java

This example retrieves User entities that are authors of posts, using the selectDistinct method to ensure each author is returned only once.

        EntityIterable postAuthors = txn.getAll("Post").selectDistinct("author");

EntityStoreDemo.java

To figure out if you should call selectManyDistinct or selectDistinct check the code that sets the links. If you use the setLink method, use selectDistinct. If you use the addLink method, use selectManyDistinct. In this example User and Post form a one-to-many relationship. A user can have many posts, but a post has only one user (author). So if you start from the "one" side (User) and select the "many" side (Post), you use selectManyDistinct. If you start from the "many" side (Post) and select the "one" side (User), you use selectDistinct.

Virtual File Systems

The third API layer in the Xodus library is the Virtual File System (VFS). VFS provides a file-system-like abstraction on top of Xodus environments, allowing you to work with files and directories in a hierarchical structure.

To use the Virtual File System API, you need to add the following dependency to your project:

    <dependency>
      <groupId>org.jetbrains.xodus</groupId>
      <artifactId>xodus-vfs</artifactId>
      <version>2.0.1</version>
    </dependency>

pom.xml

The VFS API sits on top of the Environment API and requires an Environment instance to operate. After creating the Environment, you can create a VirtualFileSystem instance by passing the environment to its constructor. Also remember to close the VirtualFileSystem instance after use with the shutdown method.

    File dbDir = new File("vfs_demo_db");

    try (Environment env = Environments.newInstance(dbDir)) {
      VirtualFileSystem vfs = new VirtualFileSystem(env);

      try {
        demonstrateFileOperations(env, vfs);
        demonstrateFilePositioning(env, vfs);
      }
      finally {
        vfs.shutdown();
      }
    }

VfsDemo.java

The following example shows you all the basic operations you can perform with the VFS API. Note that all operations must be executed within a transaction. If you only call read operations, you can start a read-only transaction.

    env.executeInTransaction(txn -> {
      try {
        // Create a new file. Throws exception if file already exists.
        jetbrains.exodus.vfs.File file1 = vfs.createFile(txn, "demo/test1.txt");
        System.out.println("Created file: " + file1.getPath() + " (descriptor: "
            + file1.getDescriptor() + ")");

        // Write content to the file
        try (OutputStream output = vfs.writeFile(txn, file1)) {
          String content = "This is a test file.\nLine 2 of content.\n";
          output.write(content.getBytes(StandardCharsets.UTF_8));
        }

        // Create file with openFile (create if not exists)
        jetbrains.exodus.vfs.File file2 = vfs.openFile(txn, "demo/test2.txt", true);
        System.out.println("Opened/created file: " + file2.getPath());

        // Write content to the second file
        try (OutputStream output = vfs.writeFile(txn, file2)) {
          String content = "This is another test file.\n";
          output.write(content.getBytes(StandardCharsets.UTF_8));
        }

        // Append content to the second file
        try (OutputStream output = vfs.appendFile(txn, file2)) {
          String appendContent = "Appending more content to test2.txt.\n";
          output.write(appendContent.getBytes(StandardCharsets.UTF_8));
        }

        // Create file with unique auto-generated path
        jetbrains.exodus.vfs.File uniqueFile = vfs.createUniqueFile(txn, "temp/unique_");
        System.out.println("Created unique file: " + uniqueFile.getPath());

        // Check if file exists by trying to open it (will return null if doesn't
        // exist)
        jetbrains.exodus.vfs.File file3 = vfs.openFile(txn, "demo/test1.txt", false);
        if (file3 != null) {
          System.out.println("File demo/test1.txt exists");
        }
        else {
          System.out.println("File demo/test1.txt does not exist");
        }

        // Rename a file
        vfs.renameFile(txn, file2, "demo/renamed_test2.txt");

        // Delete a file
        vfs.deleteFile(txn, "demo/test1.txt");

        // Return total size of all files in the VFS
        long size = vfs.diskUsage(txn);
        System.out.println("Total disk usage: " + size + " bytes");

        // List all files
        System.out.println("Files:");
        for (jetbrains.exodus.vfs.File file : vfs.getFiles(txn)) {
          System.out.println(
              " - " + file.getPath() + " (descriptor: " + file.getDescriptor() + ")");
        }

        // Read content from a file
        try (InputStream input = vfs.readFile(txn, file2)) {
          byte[] buffer = input.readAllBytes();
          String readContent = new String(buffer, StandardCharsets.UTF_8);
          System.out.println("Content of renamed_test2.txt:");
          System.out.println(readContent);
        }

      }
      catch (Exception e) {
        System.err.println("Error in file operations: " + e.getMessage());
      }
    });

VfsDemo.java

The readFile and writeFile method support a variant with a position parameter. This allows you to read or write at a specific position in the file. You find an example here.

Advanced Concepts

Encryption

Xodus supports database encryption. Built-in are the two stream cipher algorithms Salsa20 and ChaCha20, or you can provide your own cipher implementation.

Add the following dependency to your project:

    <dependency>
      <groupId>org.jetbrains.xodus</groupId>
      <artifactId>xodus-crypto</artifactId>
      <version>2.0.1</version>
    </dependency>    

pom.xml

Example of creating an encrypted Environment:

    EnvironmentConfig config = new EnvironmentConfig();
    config
        .setCipherId("jetbrains.exodus.crypto.streamciphers.ChaChaStreamCipherProvider");

    // for demo purposes hard coded, in a real application read from a secure location
    String cipherKey = "000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f";
    long iv = 314159262718281828L;

    config.setCipherKey(cipherKey);
    config.setCipherBasicIV(iv);

    try (Environment env = Environments.newInstance("./encrypted_db", config)) {
      Transaction rwTx = env.beginTransaction();
      Store sensorStore = env.openStore("sensor_data", StoreConfig.WITHOUT_DUPLICATES,
          rwTx);

      ByteIterable sensor = StringBinding.stringToEntry("sensor1");
      for (int i = 0; i < 10; i++) {
        sensorStore.put(rwTx, sensor, IntegerBinding.intToCompressedEntry(i));
      }

      rwTx.commit();

    }

EncryptionDemo.java


If you work with the Entity Store API, you first create an Environment instance and then pass it to the newInstance method.

    try (Environment env = Environments.newInstance("./encrypted_db", config);
        PersistentEntityStore store = PersistentEntityStores.newInstance(env)) {
      // Use the store as usual
    }

EncryptionDemo.java

Check out the official documentation for more information.


Backups

Xodus provides tools for creating full backups of databases. Backups can be performed on-the-fly, without affecting database operations or requiring read-only mode.

The following examples demonstrate how to back up an Environment instance using the CompressBackupUtil.backup method.

    try (Environment env = Environments.newInstance("./backup_db")) {
      Transaction rwTx = env.beginTransaction();
      Store sensorStore = env.openStore("sensor_data", StoreConfig.WITHOUT_DUPLICATES,
          rwTx);

      ByteIterable sensor = StringBinding.stringToEntry("sensor1");
      for (int i = 0; i < 10; i++) {
        sensorStore.put(rwTx, sensor, IntegerBinding.intToCompressedEntry(i));
      }

      rwTx.commit();

      File backupFile = CompressBackupUtil.backup(env,
          new File(env.getLocation(), "sensor_data_backup"), "sensor_data_", true);
      System.out.println("Backup file: " + backupFile.getAbsolutePath());
    }

BackupDemo.java

The CompressBackupUtil.backup method can also back up PersistentEntityStore instances. The method takes the following parameters:

The example above stores the backup in a subdirectory where the database is located. The name of the backup file starts with sensor_data_ and ends with a timestamp, and the file is compressed as a zip archive. The filename in this example looks like this: <location_of_db>/sensor_data_backup/sensor_data_2025-07-29-14-10-18.zip

See the official documentation for more information.

Conclusion

This concludes this tour of the Xodus database and its APIs. We have seen all the key features of Xodus and how to use them in Java. If you are looking for an embedded database for your next JVM project, Xodus is worth a try. JetBrains uses Xodus in their own products like YouTrack and Hub, so we can say that Xodus is a battle-tested database.

Explore further: