No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-10 13:27:15 -04:00
benchmarks fixes 2026-09-10 13:27:15 -04:00
src fixes 2026-09-10 13:27:15 -04:00
test fixes 2026-09-10 13:27:15 -04:00
.gitignore add zig-pkg/ to .gitignore 2026-05-31 16:02:46 +01:00
build.zig organize build.zig 2026-06-01 11:35:43 +01:00
build.zig.zon Merge upstream v0.4.0+1.0.1 (LMDB 0.9.35 -> 1.0.1) 2026-09-10 12:41:50 -04:00
example.zig fixes 2026-09-10 13:27:15 -04:00
LICENSE Update LICENSE 2025-09-23 23:26:02 -04:00
README.md fixes 2026-09-10 13:27:15 -04:00

zig-lmdb

Zig bindings for LMDB.

A fork of nDimensional/zig-lmdb with three additions:

  • Entry-returning cursor operations. goToFirstEntry, goToNextEntry, goToPreviousEntry, goToLastEntry, goToKeyEntry, and seekEntry return ?Entry, so a scan that reads values costs one mdb_cursor_get call per element instead of two.
  • Put flags. Database.put/Cursor.put take MDB_NOOVERWRITE and MDB_APPEND (plus MDB_CURRENT on cursors), and reserve exposes MDB_RESERVE, which returns a writable slice inside the database page instead of copying a value in.
  • The translated C API is exported as lmdb.c, so calls this wrapper doesn't cover are reachable without patching the build.

Everything else is upstream, unchanged.

Table of Contents

Installation

Built and tested with Zig version 0.16.0.

zig fetch --save=lmdb \
  https://forgejo.gllghr.net/d/zig-lmdb/archive/${COMMIT_HASH}.tar.gz

Usage

An LMDB environment can either have multiple named databases, or a single unnamed database.

To use a single unnamed database, open a transaction and use the txn.get, txn.set, txn.delete, and txn.cursor methods directly.

const lmdb = @import("lmdb");

pub fn main() !void {
    const env = try lmdb.Environment.init("path/to/db", .{});
    defer env.deinit();

    var txn = try lmdb.Transaction.init(env, .{ .mode = .ReadWrite });
    errdefer txn.abort();

    try txn.set("aaa", "foo");
    try txn.set("bbb", "bar");

    try txn.commit();
}

To use named databases, open the environment with a non-zero max_dbs value. Then open each named database using Transaction.database, which returns a Database struct with db.get/db.set/db.delete/db.cursor methods. You don't have to close databases, but they're only valid during the lifetime of the transaction.

const lmdb = @import("lmdb");

pub fn main() !void {
    const env = try lmdb.Environment.init("path/to/db", .{ .max_dbs = 2 });
    defer env.deinit();

    var txn = try lmdb.Transaction.init(env, .{ .mode = .ReadWrite });
    errdefer txn.abort();

    const widgets = try txn.database("widgets", .{ .create = true });
    try widgets.set("aaa", "foo");

    const gadgets = try txn.database("gadgets", .{ .create = true });
    try gadgets.set("aaa", "bar");

    try txn.commit();
}

API

Environment

pub const Environment = struct {
    pub const Options = struct {
        map_size: usize = 10 * 1024 * 1024,
        max_dbs: u32 = 0,
        max_readers: u32 = 126,
        read_only: bool = false,
        write_map: bool = false,
        no_tls: bool = false,
        no_lock: bool = false,
        no_sub_dir: bool = false,    // MDB_NOSUBDIR
        no_sync: bool = false,       // MDB_NOSYNC
        no_meta_sync: bool = false,  // MDB_NOMETASYNC
        map_async: bool = false,     // MDB_MAPASYNC
        no_mem_init: bool = false,   // MDB_NOMEMINIT
        no_read_ahead: bool = false, // MDB_NORDAHEAD
        prev_snapshot: bool = false, // MDB_PREVSNAPSHOT
        mode: u16 = 0o664,
    };

    pub const Info = struct {
        map_size: usize,
        last_pgno: usize,
        last_txnid: usize,
        max_readers: u32,
        num_readers: u32,
    };

    pub fn init(path: [*:0]const u8, options: Options) !Environment
    pub fn deinit(self: Environment) void

    pub fn transaction(self: Environment, options: Transaction.Options) !Transaction

    pub fn sync(self: Environment) !void
    pub fn info(self: Environment) !Info
    pub fn stat(self: Environment) !Stat

    pub fn resize(self: Environment, size: usize) !void // mdb_env_set_mapsize
};

no_sync and no_meta_sync trade durability for commit throughput: with the defaults every commit fsyncs, and that dominates single-write transactions (see benchmarks/). no_sync can lose the last transactions in a system crash, no_meta_sync at most the last one; neither can corrupt the database. no_mem_init and no_read_ahead are smaller wins for write-heavy and larger-than-RAM workloads respectively.

info().last_pgno lets you grow the map before a write fails: (last_pgno + 1) * stat().psize is the number of bytes in use. resize may only be called with no transactions open in this process.

Transaction

pub const Transaction = struct {
    pub const Mode = enum { ReadOnly, ReadWrite };

    pub const Options = struct {
        mode: Mode,
        parent: ?Transaction = null,
    };

    pub fn init(env: Environment, options: Options) !Transaction
    pub fn abort(self: *Transaction) void
    pub fn commit(self: *Transaction) !void

    pub fn get(self: Transaction, key: []const u8) !?[]const u8
    pub fn set(self: Transaction, key: []const u8, value: []const u8) !void
    pub fn delete(self: Transaction, key: []const u8) !void

    pub fn cursor(self: Transaction) !Cursor
    pub fn database(self: Transaction, name: ?[*:0]const u8, options: Database.Options) !Database
};

commit and abort clear the handle, so aborting after a commit (successful or not) is a no-op. This matters because LMDB ends the transaction itself when a commit fails, for instance with error.MDB_MAP_FULL, and ending it a second time frees the environment's preallocated write transaction. Both errdefer txn.abort() before try txn.commit() and a plain defer txn.abort() are safe.

Database

pub const Database = struct {
    pub const Options = struct {
        reverse_key: bool = false,
        integer_key: bool = false,
        create: bool = false,
    };

    pub const PutFlags = struct {
        no_overwrite: bool = false, // MDB_NOOVERWRITE
        append: bool = false,       // MDB_APPEND
    };

    pub fn open(txn: Transaction, name: ?[*:0]const u8, options: Options) !Database

    pub fn get(self: Database, key: []const u8) !?[]const u8
    pub fn set(self: Database, key: []const u8, value: []const u8) !void
    pub fn put(self: Database, key: []const u8, value: []const u8, flags: PutFlags) !void
    pub fn reserve(self: Database, key: []const u8, size: usize, flags: PutFlags) ![]u8
    pub fn delete(self: Database, key: []const u8) !void

    pub fn cursor(self: Database) !Cursor

    pub fn stat(self: Database) !Stat
};

set(key, value) is put(key, value, .{}). Both no_overwrite and append report a rejected write as error.MDB_KEYEXIST — for append, that means the key did not sort after every key already in the database.

reserve allocates size bytes inside the database page and returns them for the caller to fill in, avoiding a copy of the value. The returned slice is only valid until the next write in the same transaction.

const value = try db.reserve("key", 8, .{});
std.mem.writeInt(u64, value[0..8], 42, .big);

Cursor

pub const Cursor = struct {
    pub const Entry = struct { key: []const u8, value: []const u8 };

    pub const PutFlags = struct {
        no_overwrite: bool = false, // MDB_NOOVERWRITE
        append: bool = false,       // MDB_APPEND
        current: bool = false,      // MDB_CURRENT
    };

    pub fn init(db: Database) !Cursor
    pub fn deinit(self: Cursor) void

    pub fn getCurrentEntry(self: Cursor) !Entry
    pub fn getCurrentKey(self: Cursor) ![]const u8
    pub fn getCurrentValue(self: Cursor) ![]const u8

    pub fn setCurrentValue(self: Cursor, value: []const u8) !void
    pub fn deleteCurrentKey(self: Cursor) !void

    pub fn put(self: Cursor, key: []const u8, value: []const u8, flags: PutFlags) !void
    pub fn reserve(self: Cursor, key: []const u8, size: usize, flags: PutFlags) ![]u8

    pub fn goToNextEntry(self: Cursor) !?Entry
    pub fn goToPreviousEntry(self: Cursor) !?Entry
    pub fn goToLastEntry(self: Cursor) !?Entry
    pub fn goToFirstEntry(self: Cursor) !?Entry
    pub fn goToKeyEntry(self: Cursor, key: []const u8) !?Entry
    pub fn seekEntry(self: Cursor, key: []const u8) !?Entry

    pub fn goToNext(self: Cursor) !?[]const u8
    pub fn goToPrevious(self: Cursor) !?[]const u8
    pub fn goToLast(self: Cursor) !?[]const u8
    pub fn goToFirst(self: Cursor) !?[]const u8
    pub fn goToKey(self: Cursor, key: []const u8) !void

    pub fn seek(self: Cursor, key: []const u8) !?[]const u8
};

The *Entry methods return the key and the value from a single mdb_cursor_get call, which is what you want for any scan that reads values:

var next = try cursor.seekEntry(prefix);
while (next) |entry| : (next = try cursor.goToNextEntry()) {
    if (!std.mem.startsWith(u8, entry.key, prefix)) break;
    // entry.value is already here — no second call
}

The key-only methods are unchanged, and goToKeyEntry differs from goToKey in reporting a missing key as null rather than error.MDB_NOTFOUND.

setCurrentValue costs two calls, because MDB_CURRENT still needs the real key. If you already hold it, cursor.put(entry.key, value, .{ .current = true }) does the same in one. The key passed with current must be the one at the cursor's position; LMDB does not check.

⚠️ Always close cursors before committing or aborting the transaction.

Stat

pub const Stat = struct {
    psize: u32,
    depth: u32,
    branch_pages: usize,
    leaf_pages: usize,
    overflow_pages: usize,
    entries: usize,
};

c

lmdb.c is the translated LMDB header, exported as an escape hatch for calls this wrapper does not wrap, alongside lmdb.throw, which maps a return code to lmdb.Error. Handles are plain fields (env.ptr, txn.ptr, db.dbi, cursor.ptr), so dropping to the C API does not mean abandoning the wrapper:

try lmdb.throw(lmdb.c.mdb_drop(txn.ptr, db.dbi, 0));