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
db.Mergewrites 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 callingFullMerge. - When compaction sees several operands without an intervening
Put, it can callPartialMergeto 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 order — front() 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.