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:
- Consistency: Reads see a consistent snapshot of the database.
- No Write Skew: Prevents anomalies in concurrent transactions.
- Lock-Free Reads: Multiple readers can access data without blocking writers.
Append-Only Storage
Xodus uses an append-only storage mechanism:
- New data is written without overwriting old data.
- Reduces random I/O overhead (common in update-in-place databases).
- Improves durability and crash recovery.
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:
- Entities: Objects with types (like "User" or "Order").
- Properties: Named attributes (e.g., "name", "email").
- Relations: Links between entities (e.g., a "User" owns multiple "Orders").
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>
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"));
});
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
}
}
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();
}
}
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
}
}
}
});
}
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));
}
}
}
});
}
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>
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)) {
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();
});
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"));
}
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"));
}
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"));
}
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();
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");
}
});
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"));
}
});
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);
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");
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);
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());
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());
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"));
}
});
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.
Link Traversal ¶
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"));
}
}
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"));
}
}
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");
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");
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>
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();
}
}
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());
}
});
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>
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();
}
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
}
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());
}
The CompressBackupUtil.backup
method can also back up PersistentEntityStore
instances. The method takes the following parameters:
backupable
: the instance to back up (Environment or PersistentEntityStore)backupPath
: the directory where the backup file will be creatednamePrefix
: an optional prefix for the backup file namezip
: whether to create a zip archive (true) or a tar.gz archive (false)
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: