How to Store JSON in Valkey and Redis on Java

Last updated
July 20, 2026

JSON (JavaScript Object Notation) is a lightweight, text-based format for representing structured data. It is easy for people to read and cheap for machines to parse, and it is supported by essentially every modern language and runtime, which is why it has become the default way to move data between web services and APIs.

Redis and Valkey are in-memory data stores used for databases, caches and message brokers. Both can hold JSON as a native document type rather than an opaque string: a document is parsed once on the server, and individual fields inside it can then be read and updated in place, without transferring the whole value across the network.

This article covers how to do that from Java with Redisson. For a conceptual overview of the data type itself, see Redis JSON.

Do you need the JSON module?

The answer changed in 2025 and now depends on which engine and version you run.

  • Redis 8.0 and later — built in. Redis 8 merged Redis Stack and Redis Community Edition into a single distribution, Redis Open Source, and JSON ships as part of core. There is no module to load.
  • Redis 7.x and earlier — the RedisJSON module is required, either loaded manually or obtained through Redis Stack.
  • Valkey — a module is required on every version. JSON is provided by valkey-json, an official BSD-licensed module compatible with Valkey 8.0 and above. It is API- and RDB-compatible with RedisJSON v2, so clients work against it unchanged, and it ships pre-loaded in the valkey-bundle Docker image.

Managed services vary. AWS ElastiCache and MemoryDB support JSON on Redis 6.2 and later, implemented natively rather than as a module — so INFO Modules returns nothing even though the commands work. Azure supports it on Enterprise tiers only, and modules must be chosen when the cache is created because they cannot be added afterwards. Google Memorystore for Valkey enables it automatically on Valkey 8.0 and above.

Two ways to store JSON with Redisson

Redisson exposes JSON through two different objects, and the right choice depends on whether you are storing one document or many.

RJsonBucket RJsonStore
Holds a single JSON document a keyed collection of JSON documents
Analogous to RBucket RMap
Bulk read/write in one round trip no yes
Per-entry TTL no yes
Field search integration no yes
Local cache variant no yes
Edition open source Redisson PRO

Use RJsonBucket for a single configuration document, a feature-flag blob, or one cached payload under a known key. Use RJsonStore when you have many records of the same shape — sessions, products, carts — that need to be read, expired and queried independently.

A POJO to work with

Redisson converts between your own classes and JSON automatically using a codec, so no manual serialization code is needed. The class below is used throughout the examples. Note the no-argument constructor and the setters — Jackson needs both to deserialize.

public class Product {

    private String name;
    private Integer views;
    private boolean active;
    private List<String> tags = new ArrayList<>();

    public Product() {
    }

    public Product(String name, Integer views) {
        this.name = name;
        this.views = views;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getViews() {
        return views;
    }

    public void setViews(Integer views) {
        this.views = views;
    }

    public boolean isActive() {
        return active;
    }

    public void setActive(boolean active) {
        this.active = active;
    }

    public List<String> getTags() {
        return tags;
    }

    public void setTags(List<String> tags) {
        this.tags = tags;
    }
}

Storing a single document with RJsonBucket

RJsonBucket holds one JSON document under one key. It extends RBucket, so the familiar holder methods are available, plus path-based operations that work inside the stored document.

RJsonBucket<Product> bucket =
        redisson.getJsonBucket("product:1001", new JacksonCodec<>(Product.class));

// store the whole document
bucket.set(new Product("Wireless Mouse", 0));

// read the whole document
Product product = bucket.get();

// read a single field, without transferring the rest
String name = bucket.get(new JacksonCodec<>(String.class), "$.name");

// read the tags array
List<String> tags = bucket.get(
        new JacksonCodec<>(new TypeReference<List<String>>() {}), "$.tags");

// append to the array in place
long tagCount = bucket.arrayAppend("$.tags", "premium");

// conditional writes at a path
bucket.setIfAbsent("$.active", true);
bucket.setIfExists("$.name", "Wireless Mouse v2");

// remove a single field
bucket.delete("$.views");

// atomic whole-document swap
bucket.compareAndSet(new Product("Wireless Mouse", 0),
                     new Product("Wireless Mouse v2", 0));

Paths use JSONPath. The $ prefix selects the enhanced syntax, which supports wildcards, filters, array slices and recursive descent. An older restricted syntax without the $ also exists; prefer the $ form for anything new.

The key detail is that bucket.get(codec, "$.name") reads one field server-side. The alternative — storing the object as a serialized string, fetching it, parsing it, reading the field — moves the entire document over the network to answer a question about a few bytes of it.

Storing many documents with RJsonStore

RJsonStore is a distributed store of JSON documents available in Redisson PRO. It behaves much like an RMap, except that values are stored with native JSON.* commands, so each one remains addressable by path.

RJsonStore<String, Product> store =
        redisson.getJsonStore("product", new JacksonCodec<>(Product.class));

// one document
store.set("1001", new Product("Wireless Mouse", 0));

// several at once, in a single round trip
store.set(Map.of(
        "1001", new Product("Wireless Mouse", 0),
        "1002", new Product("Mechanical Keyboard", 0)));

// read one, or many
Product one = store.get("1001");
Map<String, Product> many = store.get(Set.of("1001", "1002"));

// read and remove atomically
Product taken = store.getAndDelete("1001");

// delete one, or many
boolean removed = store.delete("1001");
long count = store.delete(Set.of("1001", "1002"));

// all keys
Set<String> keys = store.readAllKeySet();

Working inside a document

Because each value is a real JSON document, parts of it can be read and modified by path, evaluated entirely on the server. Typed reads take a JsonCodec for the extracted sub-value.

// set a single field
store.set("1001", "$.name", "Wireless Mouse v2");

// merge a partial object into the document
store.merge("1001", "$", new Product("Wireless Mouse v2", 10));

// read a single field, typed
String name = store.get("1001", new JacksonCodec<>(String.class), "$.name");

// inspect structure
JsonType type = store.getType("1001", "$.name");
long fields = store.countKeys("1001");

// atomic counter — no lock, no read-modify-write race
Integer views = store.incrementAndGet("1001", "$.views", 1);

// arrays
long size = store.arrayAppend("1001", "$.tags", "premium");
long inserted = store.arrayInsert("1001", "$.tags", 0, "vip");
long length = store.arraySize("1001", "$.tags");
String last = store.arrayPop("1001", new JacksonCodec<>(String.class), "$.tags", -1);

// booleans and strings
boolean active = store.toggle("1001", "$.active");
long nameLength = store.stringAppend("1001", "$.name", " (refurbished)");

incrementAndGet is the clearest illustration of why this matters. Incrementing a counter inside a serialized string means a read, a parse, an increment, a re-serialize and a write — five steps, and a race condition unless you wrap them in a lock. Here it is one atomic server-side operation.

Most path operations have a *Multi companion — arrayAppendMulti, incrementAndGetMulti and so on — that applies to every location a multi-valued path matches and returns one result per match.

Atomic and conditional updates

// swap the document only if it currently equals an expected value
boolean swapped = store.compareAndSet("1001", oldProduct, newProduct);

// the same, for a single field
boolean fieldSwapped = store.compareAndSet(
        "1001", "$.name", "Wireless Mouse", "Wireless Mouse v2");

// replace and return the previous value
Product previous = store.getAndSet("1001", newProduct);

// write only if the key is new
boolean stored = store.setIfAbsent("1001", newProduct);

// write only if the key already exists
boolean updated = store.setIfExists("1001", newProduct);

Per-entry expiration

Each entry can carry its own time to live. Expiry is handled on the Valkey or Redis side, with no client-side eviction task.

// store with a 24-hour TTL
store.set("1001", product, Duration.ofHours(24));

// read and reset the expiration in one step
Product p = store.getAndExpire("1001", Duration.ofMinutes(30));

// milliseconds remaining
long ttl = store.remainTimeToLive("1001");

// overwrite the value but keep the current expiration
store.setAndKeepTTL("1001", updatedProduct);

This is per-entry. The store itself also implements RExpirable, so expire and clearExpire set an expiration on the whole store — a separate mechanism.

Searching documents by field

Because values are stored as JSON rather than as opaque blobs, a JSON Store can be indexed and queried by the fields of its documents through Redis Search. Point lookups by key and ad-hoc field queries then run against the same data, with no separate search system to keep in sync.

Two requirements: the index prefix must be given in <object_name>: format, and StringCodec must be used as the key codec so that fields are indexable.

RSearch search = redisson.getSearch();
search.createIndex("idx:product", IndexOptions.defaults()
                        .on(IndexType.JSON)
                        .prefix(Arrays.asList("product:")),
                   FieldIndex.text("name"));

RJsonStore<String, Product> store = redisson.getJsonStore(
        "product", StringCodec.INSTANCE, new JacksonCodec<>(Product.class));

store.set("1001", new Product("Wireless Mouse", 0));
store.set("1002", new Product("Mechanical Keyboard", 0));

// full-text match on the name field
SearchResult found = search.search("idx:product", "@name:wireless",
        QueryOptions.defaults().returnAttributes(new ReturnAttribute("name")));

// aggregation over the same index
AggregationResult aggregated = search.aggregate("idx:product", "*",
        AggregationOptions.defaults().withCursor().load("name"));

For a fuller treatment see How to Search Data Stored in Redis on Java.

Local cache for read-heavy documents

Configuration, pricing tables and catalog metadata are read constantly and written rarely, and for these the network round trip dominates the cost. RLocalCachedJsonStore keeps entries in application memory and serves reads locally — up to 45x faster than the regular implementation. Instances sharing a name are connected to the same pub/sub channel, which carries update and invalidation events so every node converges on the latest value.

LocalCachedJsonStoreOptions options = LocalCachedJsonStoreOptions.name("product")
        .keyCodec(StringCodec.INSTANCE)
        .valueCodec(new JacksonCodec<>(Product.class))
        .syncStrategy(SyncStrategy.INVALIDATE)
        .evictionPolicy(EvictionPolicy.LRU)
        .cacheSize(10000);

RLocalCachedJsonStore<String, Product> store =
        redisson.getLocalCachedJsonStore(options);

// served from the local cache after the first read, no network round trip
Product rule = store.get("1001");

// this update is propagated to every other instance's local cache
store.set("1001", updatedProduct);

Use a single local-cached instance per unique name per Redisson instance, and the same options object across all of them. See JSON client-side caching on Java for Valkey and Redis for the full set of options.

Async, Reactive and RxJava3

Every operation above is also available through the asynchronous, Reactive and RxJava3 interfaces. The method names are unchanged; only the return types differ — RFuture, Mono, and Completable/Maybe/Single respectively.

// asynchronous
RJsonStoreAsync<String, Product> store =
        redisson.getJsonStore("product", new JacksonCodec<>(Product.class));

RFuture<Void> setFuture = store.setAsync("1001", product);
RFuture<Product> getFuture = store.getAsync("1001");
RFuture<Integer> viewsFuture = store.incrementAndGetAsync("1001", "$.views", 1);

// reactive
RedissonReactiveClient reactive = redisson.reactive();
RJsonStoreReactive<String, Product> reactiveStore =
        reactive.getJsonStore("product", new JacksonCodec<>(Product.class));

Mono<Void> setMono = reactiveStore.set("1001", product);
Mono<Product> getMono = reactiveStore.get("1001");

Choosing between them

Start with RJsonBucket if you are storing one document under a known key — it is in the open-source edition and covers the whole path-based API for a single value. Move to RJsonStore when you have a collection of documents and need bulk operations, per-entry expiry, field search or a local cache.

And measure before migrating from a Hash. JSON earns its memory overhead through nesting and partial access; for a flat record of scalar fields, a Hash will usually cost less.

Getting Started With Redisson
Similar articles