RocksDB-Sharp

Merge operator

A merge operator is a user-defined function that combines an existing value with one or more operands. Calling db.Merge(key, operand) is a "read-modify-write without the read" — the operator only runs when someone later reads the key (or compaction folds operands together).

Use it for:

  • Counters / sums / max / min.
  • Append-only logs.
  • JSON / CRDT-style mutations where the merge is associative.

Upstream reference: Merge Operator wiki and Merge Operator Implementation.


How it works

sequenceDiagram participant App participant DB as RocksDB participant Op as MergeOperator App->>DB: Put("k", "hello") App->>DB: Merge("k", " world") App->>DB: Merge("k", "!") App->>DB: Get("k") DB->>Op: FullMerge("hello", [" world", "!"]) Op-->>DB: "hello world!" DB-->>App: "hello world!"
  • db.Merge writes an operand — a small record that says "apply this op to the current value".
  • On Get, RocksDB folds the most-recent committed value with all subsequent operands by calling FullMerge.
  • When compaction sees several operands without an intervening Put, it can call PartialMerge to collapse them into one.

The operator is configured on the column family options, not on the database itself:

var opts = new ColumnFamilyOptions().SetMergeOperator(myOp);

Defining an operator

The modern API uses MergeOperators.Create(name, partial, full). Both functions are ref struct-based for zero allocation.

var stringAppend = MergeOperators.Create(
    name: "StringAppend",

    // Combine multiple operands when no base value is known.
    partialMerge: (ReadOnlySpan<byte> key,
                   MergeOperators.OperandsEnumerator operands,
                   out bool success) =>
    {
        var bytes = new List<byte>();
        for (int i = 0; i < operands.Count; i++)
            bytes.AddRange(operands.Get(i).ToArray());
        success = true;
        return bytes.ToArray();
    },

    // Combine the existing value with a list of operands.
    fullMerge: (ReadOnlySpan<byte> key,
                bool hasExisting,
                ReadOnlySpan<byte> existing,
                MergeOperators.OperandsEnumerator operands,
                out bool success) =>
    {
        var bytes = hasExisting ? existing.ToArray().ToList() : new List<byte>();
        for (int i = 0; i < operands.Count; i++)
            bytes.AddRange(operands.Get(i).ToArray());
        success = true;
        return bytes.ToArray();
    });
Why two functions?

FullMerge is the authoritative semantics — it's called with the base value. PartialMerge is an optimization: compaction can fold operands together without seeing the base, if your merge is associative. If you can't partial-merge, set success = false in partialMerge and RocksDB will keep the operands until a read folds them with the base.


Wiring it up

var stringAppend = MergeOperators.Create("StringAppend", partial, full);

var cfOpts = new ColumnFamilyOptions().SetMergeOperator(stringAppend);

using var db = RocksDb.Open(
    new DbOptions().SetCreateIfMissing(),
    path,
    new ColumnFamilies(cfOpts));

db.Merge("greeting"u8, "hello"u8);
db.Merge("greeting"u8, " world"u8);

byte[] v = db.Get("greeting"u8);
Console.WriteLine(Encoding.UTF8.GetString(v)); // "hello world"

This is exactly the CustomMergeOperatorTests test in the repository.


A counter operator

var counter = MergeOperators.Create(
    "I64Add",

    partialMerge: (ReadOnlySpan<byte> _, MergeOperators.OperandsEnumerator ops, out bool ok) =>
    {
        long sum = 0;
        for (int i = 0; i < ops.Count; i++) sum += BitConverter.ToInt64(ops.Get(i));
        ok = true;
        return BitConverter.GetBytes(sum);
    },

    fullMerge: (ReadOnlySpan<byte> _, bool has, ReadOnlySpan<byte> existing,
                MergeOperators.OperandsEnumerator ops, out bool ok) =>
    {
        long sum = has ? BitConverter.ToInt64(existing) : 0;
        for (int i = 0; i < ops.Count; i++) sum += BitConverter.ToInt64(ops.Get(i));
        ok = true;
        return BitConverter.GetBytes(sum);
    });

var cfOpts = new ColumnFamilyOptions().SetMergeOperator(counter);
using var db = RocksDb.Open(new DbOptions().SetCreateIfMissing(),
                            path, new ColumnFamilies(cfOpts));

db.Merge("clicks"u8, BitConverter.GetBytes(1L));
db.Merge("clicks"u8, BitConverter.GetBytes(1L));
db.Merge("clicks"u8, BitConverter.GetBytes(1L));

long total = BitConverter.ToInt64(db.Get("clicks"u8));   // 3

Reading partials

Operands can also be merged inside a WriteBatch, atomically with other ops:

using var batch = new WriteBatch();
batch.Merge("clicks"u8, BitConverter.GetBytes(1L));
batch.Put("last-click", DateTime.UtcNow.ToString("o"));
db.Write(batch);

Caveats

Order is significant

fullMerge receives operands in insertion orderfront() is the oldest. Many merges are commutative but if yours isn't, take care.

`name` is part of the on-disk format

The operator's Name is persisted into the SST file metadata. If you change the name (or remove the operator) at startup, RocksDB will refuse to open a DB that used the old name. Treat the name like a versioning string.

Where merges run

Some merges happen on read (Get / iterator). Some happen during compaction. Heavy merge work will burn CPU at read time — if the operator is expensive, prefer compaction-time merging by making partialMerge succeed often.

© 2026 RocksDB-Sharp. All rights reserved.