Types & Handles

TypeDescription
KVStoreOpaque handle to an open database.
KVColumnFamilyOpaque handle to a column family.
KVIteratorOpaque handle to a key-value iterator.
KVStoreStatsStatistics counters (see Diagnostics).
KVStoreConfigConfiguration struct for kvstore_open_v2.

Error Codes

CodeValueDescription
KVSTORE_OK0Operation succeeded.
KVSTORE_ERROR1Generic error.
KVSTORE_BUSY5Database locked by another connection.
KVSTORE_LOCKED6Database locked within the same connection.
KVSTORE_NOMEM7Memory allocation failed.
KVSTORE_READONLY8Database is read-only.
KVSTORE_CORRUPT11Database file is corrupted.
KVSTORE_NOTFOUND12Key or column family not found.
KVSTORE_AUTH_FAILED13Wrong password or encrypted store opened without a key.
KVSTORE_PROTOCOL15Database lock protocol error.

All codes are aliases for the corresponding SQLITE_* values.

Constants

Journal Modes

ConstantValueDescription
KVSTORE_JOURNAL_DELETE0Rollback journal mode.
KVSTORE_JOURNAL_WAL1Write-Ahead Logging — recommended.

Sync Levels

ConstantValueDescription
KVSTORE_SYNC_OFF0No fsync — fastest, not crash-safe.
KVSTORE_SYNC_NORMAL1WAL-safe — survives process crash (default).
KVSTORE_SYNC_FULL2Power-safe — fsync on every commit.

Checkpoint Modes

ConstantValueDescription
KVSTORE_CHECKPOINT_PASSIVE0Non-blocking copy; may not flush all frames.
KVSTORE_CHECKPOINT_FULL1Wait for writers, then copy all frames.
KVSTORE_CHECKPOINT_RESTART2Like FULL, then reset WAL write position.
KVSTORE_CHECKPOINT_TRUNCATE3Like RESTART, then truncate WAL file to zero.

Other

ConstantValueDescription
KVSTORE_MAX_COLUMN_FAMILIES64Maximum column families per database.
KVSTORE_NO_TTL-1Sentinel: key exists but has no expiry.

Configuration — KVStoreConfig

typedef struct KVStoreConfig KVStoreConfig;
struct KVStoreConfig {
  int journalMode;   /* KVSTORE_JOURNAL_WAL (default) or KVSTORE_JOURNAL_DELETE */
  int syncLevel;     /* KVSTORE_SYNC_NORMAL (default), _OFF, or _FULL */
  int cacheSize;     /* page cache in pages (0 = default = 2000 pages ≈ 8 MB) */
  int pageSize;      /* page size in bytes (0 = 4096; new databases only) */
  int readOnly;      /* 1 = open read-only; default 0 */
  int busyTimeout;   /* ms to retry on SQLITE_BUSY (0 = fail immediately) */
  int walSizeLimit;  /* auto-checkpoint every N commits in WAL mode (0 = off) */
};
FieldDefaultNotes
journalModeKVSTORE_JOURNAL_WALWAL strongly recommended.
syncLevelKVSTORE_SYNC_NORMALSafe against process crash.
cacheSize2000 (~8 MB)Larger cache improves read-heavy loads.
pageSize4096 bytesIgnored for existing databases.
readOnly0
busyTimeout0 (fail immediately)Set > 0 for multi-process workloads.
walSizeLimit0 (disabled)Auto-checkpoint every N committed writes.

Database Lifecycle

kvstore_open_v2

int kvstore_open_v2( const char *zFilename, /* path to file; NULL = in-memory */ KVStore **ppKV, /* out: handle */ const KVStoreConfig *pConfig /* NULL = all defaults */ );

Open or create a database with full configuration control. Pass NULL for pConfig to use all defaults (WAL mode, NORMAL sync, 8 MB cache). Returns KVSTORE_AUTH_FAILED if the file is an encrypted store — use kvstore_open_encrypted instead.

/* default open */
KVStore *kv;
kvstore_open_v2("mydb.db", &kv, NULL);

/* fully configured */
KVStoreConfig cfg = {0};
cfg.journalMode = KVSTORE_JOURNAL_WAL;
cfg.syncLevel   = KVSTORE_SYNC_FULL;
cfg.cacheSize   = 4000;
cfg.busyTimeout = 5000;
kvstore_open_v2("mydb.db", &kv, &cfg);

/* read-only */
KVStoreConfig ro = {0};
ro.readOnly = 1;
kvstore_open_v2("mydb.db", &kv, &ro);
Returns

KVSTORE_OK on success, error code otherwise.

kvstore_open

int kvstore_open( const char *zFilename, KVStore **ppKV, int journalMode /* KVSTORE_JOURNAL_WAL or _DELETE */ );

Simplified open. Equivalent to kvstore_open_v2 with only journalMode set; all other fields use defaults. Returns KVSTORE_AUTH_FAILED if the file is an encrypted store.

KVStore *kv = NULL;
kvstore_open("mydata.db", &kv, KVSTORE_JOURNAL_WAL);

kvstore_close

int kvstore_close(KVStore *pKV);

Close the database and free all resources. Any uncommitted transaction is rolled back. Runs a WAL checkpoint before closing.

Key-Value Operations — Default Column Family

kvstore_put

int kvstore_put( KVStore *pKV, const void *pKey, int nKey, const void *pValue, int nValue );

Insert or update a key-value pair. If the key already exists, its value is replaced. Keys and values are binary-safe. Without an explicit transaction, each call auto-commits.

kvstore_get

int kvstore_get( KVStore *pKV, const void *pKey, int nKey, void **ppValue, int *pnValue );

Retrieve a value by key. The returned buffer is heap-allocated by SNKV. Caller must free with sqliteFree() / snkv_free().

void *value = NULL; int vlen = 0;
int rc = kvstore_get(kv, "user:1", 6, &value, &vlen);
if (rc == KVSTORE_OK) {
    printf("%.*s\n", vlen, (char*)value);
    sqliteFree(value);
}
Returns

KVSTORE_OK if found · KVSTORE_NOTFOUND if key does not exist.

kvstore_delete

int kvstore_delete(KVStore *pKV, const void *pKey, int nKey);

Delete a key. Returns KVSTORE_NOTFOUND if the key did not exist.

kvstore_exists

int kvstore_exists(KVStore *pKV, const void *pKey, int nKey, int *pExists);

Check if a key exists without reading its value. More efficient than kvstore_get for existence checks. Sets *pExists to 1 if found, 0 otherwise.

Column Family Management

Column families provide multiple logical key-value namespaces within a single file. Each is stored in its own B-tree. Maximum KVSTORE_MAX_COLUMN_FAMILIES (64) per database.

kvstore_cf_create

int kvstore_cf_create(KVStore *pKV, const char *zName, KVColumnFamily **ppCF);

Create a new column family. zName max 255 characters, must be unique. *ppCF receives the handle.

kvstore_cf_open

int kvstore_cf_open(KVStore *pKV, const char *zName, KVColumnFamily **ppCF);

Open an existing column family. Returns KVSTORE_NOTFOUND if it does not exist.

kvstore_cf_get_default

int kvstore_cf_get_default(KVStore *pKV, KVColumnFamily **ppCF);

Get a handle to the default column family. Always exists; created automatically on open.

kvstore_cf_drop

int kvstore_cf_drop(KVStore *pKV, const char *zName);

Delete a column family and all its data permanently. The default column family cannot be dropped. Also removes any hidden TTL index CFs.

kvstore_cf_list

int kvstore_cf_list(KVStore *pKV, char ***pazNames, int *pnCount);

List all column families. Caller must free each name and the array with sqliteFree().

char **names = NULL; int count = 0;
kvstore_cf_list(kv, &names, &count);
for (int i = 0; i < count; i++) {
    printf("%s\n", names[i]);
    sqliteFree(names[i]);
}
sqliteFree(names);

kvstore_cf_close

void kvstore_cf_close(KVColumnFamily *pCF);

Release the column family handle. Does not delete data.

CF Key-Value Operations

Identical to the default-CF variants but take a KVColumnFamily* handle.

int kvstore_cf_put(KVColumnFamily *pCF, const void *pKey, int nKey, const void *pValue, int nValue); int kvstore_cf_get(KVColumnFamily *pCF, const void *pKey, int nKey, void **ppValue, int *pnValue); int kvstore_cf_delete(KVColumnFamily *pCF, const void *pKey, int nKey); int kvstore_cf_exists(KVColumnFamily *pCF, const void *pKey, int nKey, int *pExists);
Note: Caller must free the value from kvstore_cf_get with sqliteFree().

Iterators

Iterate all key-value pairs in ascending lexicographic key order.

kvstore_iterator_create / kvstore_cf_iterator_create

int kvstore_iterator_create(KVStore *pKV, KVIterator **ppIter); int kvstore_cf_iterator_create(KVColumnFamily *pCF, KVIterator **ppIter);

Create a forward iterator for the default or a specific column family.

kvstore_iterator_first / next / eof

int kvstore_iterator_first(KVIterator *pIter); int kvstore_iterator_next(KVIterator *pIter); int kvstore_iterator_eof(KVIterator *pIter); /* returns 1 at end */

Position at first entry, advance to next, or test for end-of-sequence.

kvstore_iterator_key / value

int kvstore_iterator_key(KVIterator *pIter, void **ppKey, int *pnKey); int kvstore_iterator_value(KVIterator *pIter, void **ppValue, int *pnValue);

Read the current key or value. Pointers are owned by the iterator — do not free them. Valid until the next next() or close() call.

KVIterator *it = NULL;
kvstore_iterator_create(kv, &it);
kvstore_iterator_first(it);
while (!kvstore_iterator_eof(it)) {
    void *key, *val; int klen, vlen;
    kvstore_iterator_key(it, &key, &klen);
    kvstore_iterator_value(it, &val, &vlen);
    printf("%.*s\n", klen, (char*)key);
    kvstore_iterator_next(it);
}
kvstore_iterator_close(it);

kvstore_iterator_close

void kvstore_iterator_close(KVIterator *pIter);

Release the iterator and its cursor. Always call this when done.

Prefix Iterators

Pre-positioned at the first key matching the prefix. Stop automatically when keys no longer match. Do not call kvstore_iterator_first() — read directly, then call next().

int kvstore_prefix_iterator_create( KVStore *pKV, const void *pPrefix, int nPrefix, KVIterator **ppIter ); int kvstore_cf_prefix_iterator_create( KVColumnFamily *pCF, const void *pPrefix, int nPrefix, KVIterator **ppIter );
KVIterator *it = NULL;
kvstore_prefix_iterator_create(kv, "user:", 5, &it);
/* already at first match — do NOT call first() */
while (!kvstore_iterator_eof(it)) {
    void *key; int klen;
    kvstore_iterator_key(it, &key, &klen);
    kvstore_iterator_next(it);
}
kvstore_iterator_close(it);

Reverse Iterators

Traverse keys in descending order. Use kvstore_iterator_last() and kvstore_iterator_prev() instead of first() and next(). All key/value/eof/close accessors work identically.

int kvstore_reverse_iterator_create(KVStore *pKV, KVIterator **ppIter); int kvstore_cf_reverse_iterator_create(KVColumnFamily *pCF, KVIterator **ppIter); /* Reverse prefix — pre-positioned at last matching key */ int kvstore_reverse_prefix_iterator_create( KVStore *pKV, const void *pPrefix, int nPrefix, KVIterator **ppIter ); int kvstore_cf_reverse_prefix_iterator_create( KVColumnFamily *pCF, const void *pPrefix, int nPrefix, KVIterator **ppIter ); int kvstore_iterator_last(KVIterator *pIter); /* seek to last key */ int kvstore_iterator_prev(KVIterator *pIter); /* move to previous key */
/* Full reverse scan */
KVIterator *it = NULL;
kvstore_reverse_iterator_create(kv, &it);
kvstore_iterator_last(it);
while (!kvstore_iterator_eof(it)) {
    void *key; int klen;
    kvstore_iterator_key(it, &key, &klen);
    kvstore_iterator_prev(it);
}
kvstore_iterator_close(it);

/* Reverse prefix — do NOT call last(), already positioned */
kvstore_reverse_prefix_iterator_create(kv, "user:", 5, &it);
while (!kvstore_iterator_eof(it)) {
    kvstore_iterator_prev(it);
}
kvstore_iterator_close(it);

Iterator Seek

Jump an open iterator to an arbitrary key in O(log N) time. Works on forward and reverse iterators, including prefix iterators.

kvstore_iterator_seek

int kvstore_iterator_seek(KVIterator *pIter, const void *pKey, int nKey);

Reposition the iterator without closing and reopening it.

  • Forward iterator — seek to the first key >= (pKey, nKey).
  • Reverse iterator — seek to the last key <= (pKey, nKey).

If the seek lands outside a prefix iterator’s prefix, the iterator immediately enters the eof state. Call kvstore_iterator_key / kvstore_iterator_next as usual after a successful seek.

ReturnMeaning
KVSTORE_OKSeek succeeded (check eof to know if a key was found).
KVSTORE_ERRORInvalid arguments.
/* forward: scan from "m" onwards */
KVIterator *it = NULL;
kvstore_iterator_create(kv, &it);
kvstore_iterator_seek(it, "m", 1);
while (!kvstore_iterator_eof(it)) {
    void *key; int klen;
    kvstore_iterator_key(it, &key, &klen);
    kvstore_iterator_next(it);
}
kvstore_iterator_close(it);

/* reverse: scan from "m" backwards */
kvstore_reverse_iterator_create(kv, &it);
kvstore_iterator_seek(it, "m", 1);
while (!kvstore_iterator_eof(it)) {
    kvstore_iterator_prev(it);
}
kvstore_iterator_close(it);

Transactions

int kvstore_begin(KVStore *pKV, int wrflag); /* 1=write, 0=read */ int kvstore_commit(KVStore *pKV); int kvstore_rollback(KVStore *pKV);

Explicit ACID transactions for batching operations atomically. Without an explicit transaction, each put/delete auto-commits. Wrapping bulk writes in a single transaction is significantly faster.

kvstore_begin(kv, 1);
kvstore_put(kv, "key1", 4, "val1", 4);
kvstore_put(kv, "key2", 4, "val2", 4);
kvstore_commit(kv);   /* both writes are atomic */
KVSTORE_BUSY: Set cfg.busyTimeout > 0 to auto-retry on lock contention, or handle the return code and retry manually.

TTL / Key Expiry

Per-key expiry with zero overhead for stores that never use TTL. Hidden index CFs are created lazily on first use and are invisible in kvstore_cf_list.

kvstore_put_ttl / kvstore_get_ttl

int64_t kvstore_now_ms(void); /* current time in ms since Unix epoch */ int kvstore_put_ttl( KVStore *pKV, const void *pKey, int nKey, const void *pValue, int nValue, int64_t expire_ms /* absolute ms since epoch; 0 = no TTL */ ); int kvstore_get_ttl( KVStore *pKV, const void *pKey, int nKey, void **ppValue, int *pnValue, int64_t *pnRemaining /* may be NULL */ );
/* write key that expires in 30 minutes */
int64_t exp = kvstore_now_ms() + 30 * 60 * 1000;
kvstore_put_ttl(kv, "sess:abc", 8, token, tlen, exp);

/* read with lazy expiry */
void *val = NULL; int nVal = 0; int64_t rem = 0;
int rc = kvstore_get_ttl(kv, "sess:abc", 8, &val, &nVal, &rem);
if (rc == KVSTORE_OK) { /* rem = ms remaining */ sqliteFree(val); }

kvstore_ttl_remaining

int kvstore_ttl_remaining(KVStore *pKV, const void *pKey, int nKey, int64_t *pnRemaining);
Return*pnRemainingMeaning
KVSTORE_OKKVSTORE_NO_TTL (-1)Key exists, no expiry.
KVSTORE_OK0Key just expired (lazy delete performed).
KVSTORE_OKN > 0N ms remain.
KVSTORE_NOTFOUNDKey does not exist.

kvstore_purge_expired

int kvstore_purge_expired(KVStore *pKV, int *pnDeleted); /* pnDeleted may be NULL */

Scan the expiry index and delete all expired keys in one write transaction. O(expired keys) — uses sorted expire-time prefix to stop at first non-expired entry.

CF-level TTL: All four functions have CF equivalents operating on a KVColumnFamily* handle. Each user CF maintains independent TTL indexes; purging CF A never touches CF B.

kvstore_cf_put_ttl / kvstore_cf_get_ttl

int kvstore_cf_put_ttl( KVColumnFamily *pCF, const void *pKey, int nKey, const void *pValue, int nValue, int64_t expire_ms /* absolute ms since epoch; 0 = no TTL */ ); int kvstore_cf_get_ttl( KVColumnFamily *pCF, const void *pKey, int nKey, void **ppValue, int *pnValue, int64_t *pnRemaining /* may be NULL */ );

CF-level equivalents of kvstore_put_ttl / kvstore_get_ttl. Semantics are identical; the TTL indexes are created lazily per CF and are fully independent.

kvstore_cf_ttl_remaining

int kvstore_cf_ttl_remaining( KVColumnFamily *pCF, const void *pKey, int nKey, int64_t *pnRemaining /* must not be NULL */ );

Return remaining TTL for a key in this column family without fetching the value. Return codes and *pnRemaining semantics are identical to kvstore_ttl_remaining.

kvstore_cf_purge_expired

int kvstore_cf_purge_expired(KVColumnFamily *pCF, int *pnDeleted); /* pnDeleted may be NULL */

Scan and delete all expired keys in this column family only, in a single write transaction. O(expired keys). Purging CF A never affects other column families.

Conditional Insert (Put-If-Absent)

Atomically insert a key-value pair only when the key does not already exist. Expired keys (lazy TTL) are treated as absent. Supports an optional TTL on the new entry.

kvstore_put_if_absent / kvstore_cf_put_if_absent

int kvstore_put_if_absent( KVStore *pKV, const void *pKey, int nKey, const void *pValue, int nValue, int64_t expire_ms, /* 0 = no TTL */ int *pInserted /* 1=inserted, 0=already existed; may be NULL */ ); int kvstore_cf_put_if_absent(KVColumnFamily *pCF, /* ... same params ... */);
expire_msBehaviour
0No TTL — new entry is permanent.
> 0Absolute expiry in ms since Unix epoch (kvstore_now_ms() + delta_ms).

The existence check and the write are performed inside a single write transaction, so no other writer can insert the same key concurrently.

int inserted = 0;
kvstore_put_if_absent(kv, "lock", 4, "owner", 5, 0, &inserted);
if (inserted) {
    puts("acquired lock");
} else {
    puts("lock already held");
}

/* with TTL: session token that expires in 1 hour */
int64_t exp = kvstore_now_ms() + 3600 * 1000;
kvstore_put_if_absent(kv, session_id, sid_len, token, tok_len, exp, NULL);

Bulk Clear

Remove every key-value pair from a column family in a single atomic write transaction. TTL index entries are also cleared. Much faster than iterating and deleting individually.

kvstore_clear / kvstore_cf_clear

int kvstore_clear(KVStore *pKV); int kvstore_cf_clear(KVColumnFamily *pCF);

Truncates the column family’s B-tree in O(pages) time using SQLite’s BtreeClearTable — the B-tree structure (root page) is preserved so future inserts work immediately without re-opening.

Precondition: No open iterators pointing at the column family may exist when clear is called. BtreeClearTable invalidates all cursors on the cleared table; accessing a stale cursor is undefined behaviour.
/* flush a rate-limit namespace at midnight */
kvstore_cf_clear(rate_limit_cf);

/* wipe everything in the default CF */
kvstore_clear(kv);

Key Count

Count keys in a column family without iterating over them. Uses SQLite’s sqlite3BtreeCount which reads only page headers — O(pages) ≈ O(N/100).

kvstore_count / kvstore_cf_count

int kvstore_count(KVStore *pKV, int64_t *pnCount); int kvstore_cf_count(KVColumnFamily *pCF, int64_t *pnCount);

Counts only the main data B-tree of the column family. TTL index CFs are separate B-trees and are not included. Expired-but-not-yet-purged keys are included in the count — call kvstore_purge_expired first for an exact live count.

Uncommitted writes: If a write transaction is active, the count reflects committed data only. Commit first for an accurate count that includes pending writes.
int64_t n;
kvstore_count(kv, &n);
printf("default CF has %lld entries\n", (long long)n);

kvstore_purge_expired(kv, NULL);
kvstore_count(kv, &n);
printf("live entries after purge: %lld\n", (long long)n);

Diagnostics & Maintenance

kvstore_errmsg

const char *kvstore_errmsg(KVStore *pKV);

Return the last error message string. Do not free. Valid until the next operation on the same handle.

kvstore_stats / kvstore_stats_reset

int kvstore_stats(KVStore *pKV, KVStoreStats *pStats); int kvstore_stats_reset(KVStore *pKV); /* zero all cumulative counters */

kvstore_stats fills the KVStoreStats struct. All counters are cumulative from open (or last reset). kvstore_stats_reset zeros them; nDbPages is a live gauge and is never zeroed.

FieldTypeDescription
nPutsuint64_tTotal put operations (includes put_ttl, put_if_absent).
nGetsuint64_tTotal get operations (includes get_ttl).
nDeletesuint64_tTotal delete operations.
nIterationsuint64_tTotal iterators created.
nErrorsuint64_tTotal errors encountered.
nBytesReaduint64_tTotal value bytes returned by get operations.
nBytesWrittenuint64_tTotal (key + value) bytes written by put operations.
nWalCommitsuint64_tWrite transactions successfully committed.
nCheckpointsuint64_tWAL checkpoints performed (auto + explicit).
nTtlExpireduint64_tKeys lazily expired on get / exists calls.
nTtlPurgeduint64_tKeys removed by kvstore_purge_expired.
nDbPagesuint64_tCurrent total pages in the database file (gauge, not zeroed by reset).

kvstore_integrity_check

int kvstore_integrity_check(KVStore *pKV, char **pzErrMsg);

Walk the entire B-tree and verify structural integrity. Returns KVSTORE_OK or KVSTORE_CORRUPT with details in *pzErrMsg. Caller must free *pzErrMsg with sqliteFree().

kvstore_sync

int kvstore_sync(KVStore *pKV);

Force all pending changes to disk. If a write transaction is active, performs a commit-and-reopen cycle to flush the WAL.

kvstore_incremental_vacuum

int kvstore_incremental_vacuum(KVStore *pKV, int nPage);

Reclaim unused pages and shrink the database file. Pass 0 to free all unused pages, or a positive nPage to limit work per call (good for latency-sensitive apps).

kvstore_incremental_vacuum(kv, 0);    /* free all */
kvstore_incremental_vacuum(kv, 50);   /* incremental: 50 pages */

kvstore_checkpoint

int kvstore_checkpoint(KVStore *pKV, int mode, int *pnLog, int *pnCkpt);

Copy WAL frames back into the main database file. pnLog and pnCkpt may be NULL. Requires no active write transaction. Non-WAL databases: no-op returning KVSTORE_OK.

int nLog = 0, nCkpt = 0;
kvstore_checkpoint(kv, KVSTORE_CHECKPOINT_PASSIVE, &nLog, &nCkpt);
printf("%d frames total, %d checkpointed\n", nLog, nCkpt);

/* reclaim WAL disk space */
kvstore_checkpoint(kv, KVSTORE_CHECKPOINT_TRUNCATE, NULL, NULL);

Memory Management

MacroDescription
sqliteMalloc(n)Allocate n zero-initialised bytes.
sqliteFree(p)Free memory allocated by SNKV.
sqliteRealloc(p, n)Resize allocation.
sqliteStrDup(s)Duplicate a C string (equivalent to sqlite3_mprintf("%s", s)).
snkv_malloc(n)Alias for sqliteMalloc (single-header build).
snkv_free(p)Alias for sqliteFree (single-header build).
Rule: Any pointer returned by kvstore_get, kvstore_cf_get, kvstore_cf_list, or kvstore_integrity_check must be freed with sqliteFree(). Iterator key/value pointers are iterator-owned — do NOT free them.

Thread Safety

  • All kvstore_* calls are mutex-protected — a single KVStore handle is safe across threads.
  • In WAL mode, multiple readers run concurrently with a single writer.
  • Only one write transaction can be active at a time; concurrent writes return KVSTORE_BUSY.
  • For maximum throughput, each thread can open its own KVStore to the same file.
  • KVIterator handles are not thread-safe — do not share across threads.

Encryption

Per-value AES-grade encryption using XChaCha20-Poly1305 (via Monocypher). Passwords are stretched with Argon2id. All existing APIs — put, get, delete, iterate, TTL, column families — work transparently on encrypted stores. Keys are stored in plaintext; only values are encrypted.

kvstore_open_encrypted

int kvstore_open_encrypted( const char *zFilename, const void *pPassword, int nPassword, KVStore **ppKV, const KVStoreConfig *pConfig /* may be NULL for defaults */ );

Open or create an encrypted store. On a fresh file a new Argon2id-derived key is generated from the password. On an existing encrypted file the password is verified before access is granted. If the file is a plain (non-encrypted) store with existing data, all values are encrypted in-place using the supplied password — the store becomes fully encrypted transparently. Returns KVSTORE_AUTH_FAILED only if the password is wrong on an already-encrypted file. *ppKV is set to NULL on any error — no resource leak.

/* Create / open encrypted */
KVStore *kv = NULL;
int rc = kvstore_open_encrypted("mydb.db", "hunter2", 7, &kv, NULL);
if (rc == KVSTORE_AUTH_FAILED) { puts("wrong password"); }

/* All standard operations work transparently */
kvstore_put(kv, "key", 3, "secret", 6);
kvstore_close(kv);

kvstore_is_encrypted

int kvstore_is_encrypted(KVStore *pKV);

Returns 1 if the store was opened with kvstore_open_encrypted, 0 otherwise.

kvstore_reencrypt

int kvstore_reencrypt(KVStore *pKV, const void *pNewPassword, int nNewPassword);

Change the encryption password in-place. Derives a new key from the new password, re-encrypts every value in every column family, and updates the authentication metadata — all in a single atomic transaction. The old password is invalid immediately after this call returns.

kvstore_reencrypt(kv, "new-pass", 8);
/* kv is still open and usable with the new password */

kvstore_remove_encryption

int kvstore_remove_encryption(KVStore *pKV);

Decrypt all values in-place and remove the authentication metadata. After this call the store is a plain (non-encrypted) SNKV database and can be opened with kvstore_open or kvstore_open_v2.

kvstore_remove_encryption(kv);
kvstore_close(kv);

/* Now opens without a password */
KVStore *plain = NULL;
kvstore_open("mydb.db", &plain, KVSTORE_JOURNAL_WAL);

kvstore_now_ms

int64_t kvstore_now_ms(void);

Current time in milliseconds since the Unix epoch. Useful for computing absolute TTL expiry timestamps: kvstore_now_ms() + delta_ms.

Cryptographic details:
  • KDF: Argon2id (m=64 MB, t=3 iterations) — password → 256-bit key
  • Cipher: XChaCha20-Poly1305 — 24-byte random nonce + 16-byte MAC per value
  • Overhead: 40 bytes per stored value (nonce + MAC)
  • Key scope: wiped from memory on kvstore_close
  • Column families: all CFs share one key; re-encryption covers all CFs atomically

Vector Store

An HNSW approximate nearest-neighbour index built on top of KVStore, using the usearch C SDK. Vectors and KV data share a single .db file. The index is rebuilt from the database on open and saved to a .usearch sidecar on close for fast reload. All writes are atomic KVStore transactions; the usearch index is updated after each successful commit.

Include kvstore_vec.h and link against libsnkv_vec.a (built with make vector). The core store (libsnkv.a / snkv.h) does not include the vector layer — it requires g++ to compile the usearch C++ core.

Types & Constants

Opaque handle

KVVecStoreOpaque handle returned by kvstore_vec_open.

Result structs

KVVecSearchResultpKey/nKey, pValue/nValue, distance, pMetadata/nMetadata. Free with kvstore_vec_free_results.
KVVecKeyResultpKey/nKey, distance. Free with kvstore_vec_free_key_results.
KVVecStatsFilled by kvstore_vec_stats. See Stats section.
KVVecItemOne item for kvstore_vec_put_batch: pKey/nKey, pVal/nVal, pVec, pMeta/nMeta.

Distance spaces

KVVEC_SPACE_L20Squared Euclidean (‖a−b‖²) — distances are comparable but not sqrt L2; default
KVVEC_SPACE_COSINE1Cosine distance (1 − dot(a,b) / (‖a‖·‖b‖))
KVVEC_SPACE_IP2Inner product (negative dot product)
Note on L2: KVVEC_SPACE_L2 returns squared Euclidean distance — squaring is omitted for performance. Relative ordering is identical to true L2, so nearest-neighbour results are unaffected. To recover metric distance, take sqrtf(result.distance).

Index precision (dtype)

KVVEC_DTYPE_F320float32 — full precision (default)
KVVEC_DTYPE_F161float16 — half RAM, negligible recall loss
KVVEC_DTYPE_I82int8 — quarter RAM, cosine-like metrics only

Extra error codes

KVVEC_DIM_MISMATCH20Opened with wrong dim for existing store
KVVEC_DTYPE_MISMATCH21Opened with wrong dtype for existing store
KVVEC_SPACE_MISMATCH22Opened with wrong space for existing store
KVVEC_INDEX_DROPPED23Index has been dropped; search unavailable
KVVEC_INDEX_EMPTY24Index is empty; put a vector first
KVVEC_BAD_VECTOR25Vector shape or value is invalid

Open / Close

kvstore_vec_open

int kvstore_vec_open( const char *zPath, int dim, int space, /* KVVEC_SPACE_* */ int connectivity, /* HNSW M; 0 → default 16 */ int expansion_add, /* ef_construction; 0 → default 128 */ int expansion_search, /* ef at query time; 0 → default 64 */ int dtype, /* KVVEC_DTYPE_* */ const uint8_t *pPassword, /* NULL for plain store */ int nPassword, KVVecStore **ppVS );

Open or create a vector store. dim, space, and dtype are immutable after the first open — passing different values on a subsequent open returns the corresponding mismatch error. Pass pPassword/nPassword for an encrypted store; sidecar persistence is disabled for encrypted stores. *ppVS is NULL on any error.

KVVecStore *vs = NULL;
int rc = kvstore_vec_open(
    "store.db", 128, KVVEC_SPACE_COSINE,
    0, 0, 0,            /* defaults */
    KVVEC_DTYPE_F32,
    NULL, 0,              /* plain (unencrypted) */
    &vs
);
if (rc == KVVEC_DIM_MISMATCH) puts("wrong dim for existing store");

kvstore_vec_close

void kvstore_vec_close(KVVecStore *pVS);

Close the store and free all resources. For unencrypted file-backed stores, saves the HNSW graph to {path}.usearch and the next-id stamp to {path}.usearch.nid so the next open can skip the O(n·dim) rebuild. Safe to call with NULL.

Writes

kvstore_vec_put

int kvstore_vec_put( KVVecStore *pVS, const void *pKey, int nKey, const void *pVal, int nVal, const float *pVec, /* dim float32 values */ int64_t expire_ms, /* 0 = no TTL */ const void *pMeta, int nMeta /* NULL/0 = preserve; "" nMeta=0 = clear */ );

Insert or update a key/value pair and add its vector to the HNSW index. The KVStore write (all 5 internal CFs) is one atomic transaction; the usearch index update happens after commit. If the key already exists, the old usearch label is removed before the new one is added (count stays constant on overwrite).

float vec[128] = { /* ... */ };
kvstore_vec_put(vs, "doc:1", 5, "hello", 5,
                vec,
                0,                     /* no TTL */
                "{\"tag\":\"ai\"}", 12);  /* metadata */

kvstore_vec_put_batch

int kvstore_vec_put_batch( KVVecStore *pVS, const KVVecItem *pItems, int nItems, int64_t expire_ms /* applied uniformly; 0 = no TTL */ );

Insert or update nItems records in one atomic transaction. Last-write-wins for duplicate keys within the batch. All usearch insertions happen after the commit.

KVVecItem items[3] = {
    { "k1", 2, "val1", 4, vec1, NULL, 0 },
    { "k2", 2, "val2", 4, vec2, NULL, 0 },
    { "k3", 2, "val3", 4, vec3, meta, nMeta },
};
kvstore_vec_put_batch(vs, items, 3, 0);

kvstore_vec_kv_put

int kvstore_vec_kv_put(KVVecStore *pVS, const void *pKey, int nKey, const void *pVal, int nVal);

Plain KV write — does not update the vector index. Useful for storing non-vector data (config, counters) alongside vector keys in the same file.

Reads

kvstore_vec_get

int kvstore_vec_get(KVVecStore *pVS, const void *pKey, int nKey, void **ppVal, int *pnVal);

Fetch value bytes. TTL-aware: returns KVSTORE_NOTFOUND and lazily deletes the key if it has expired. Caller must snkv_free(*ppVal).

kvstore_vec_get_vector

int kvstore_vec_get_vector(KVVecStore *pVS, const void *pKey, int nKey, float **ppVec, int *pnFloats);

Fetch the stored float32 vector. *ppVec is a flat array of dim floats. Caller must snkv_free(*ppVec).

kvstore_vec_get_metadata

int kvstore_vec_get_metadata(KVVecStore *pVS, const void *pKey, int nKey, void **ppMeta, int *pnMeta);

Fetch JSON metadata bytes. Returns KVSTORE_OK with *ppMeta=NULL when no metadata has ever been written to this store (tags CF does not yet exist). Returns KVSTORE_NOTFOUND with *ppMeta=NULL when the tags CF exists but this specific key has no metadata. Both cases are not errors. Caller must snkv_free(*ppMeta) on KVSTORE_OK with non-NULL result.

kvstore_vec_contains

int kvstore_vec_contains(KVVecStore *pVS, const void *pKey, int nKey);

Returns 1 if the key exists and is not expired, 0 otherwise.

kvstore_vec_count

int64_t kvstore_vec_count(KVVecStore *pVS);

Returns the number of active (non-deleted) vectors in the HNSW index (usearch_size).

Delete

kvstore_vec_delete

int kvstore_vec_delete(KVVecStore *pVS, const void *pKey, int nKey);

Delete the key from the KV store, all 5 vector CFs, and the HNSW index — all in one atomic transaction. Returns KVSTORE_NOTFOUND if the key does not exist.

TTL

Pass a non-zero expire_ms to kvstore_vec_put (absolute milliseconds since the Unix epoch — use kvstore_now_ms() + delta). Expired vectors are filtered out transparently during search and lazily deleted on get. Use kvstore_vec_purge_expired for bulk cleanup.

/* Expire in 60 seconds */
int64_t exp = kvstore_now_ms() + 60000;
kvstore_vec_put(vs, "sess:abc", 8, token, nToken, vec, exp, NULL, 0);

/* Bulk-delete all expired vectors */
int nPurged = 0;
kvstore_vec_purge_expired(vs, &nPurged);

Stats

kvstore_vec_stats

int kvstore_vec_stats(KVVecStore *pVS, KVVecStats *pStats);

Fill *pStats with current index configuration and runtime state. Always returns KVSTORE_OK.

dimVector dimension
spaceKVVEC_SPACE_*
dtypeKVVEC_DTYPE_*
connectivityHNSW M parameter
expansion_addef_construction
expansion_searchef at query time
countActive vectors in HNSW index (usearch_size)
capacityAllocated HNSW capacity
fill_ratiocount / capacity — 0–1 ratio (multiply by 100 for %). Auto-doubles at 0.9.
vec_cf_countEntries in _snkv_vec_ CF (may include expired)
has_metadata1 if _snkv_vec_tags_ CF exists
sidecar_enabled1 for unencrypted file-backed stores

Maintenance

kvstore_vec_purge_expired

int kvstore_vec_purge_expired(KVVecStore *pVS, int *pnDeleted);

Scan the _snkv_vec_ CF and delete all expired vectors from every CF and the HNSW index in one atomic transaction. *pnDeleted (may be NULL) is set to the number removed. Use this in preference to kvstore_purge_expired to keep vector CFs in sync. Returns KVSTORE_OK (0 deleted) when there are no TTL entries.

kvstore_vec_drop_index

int kvstore_vec_drop_index(KVVecStore *pVS);

Drop all five _snkv_vec*_ column families and free the in-memory HNSW index. KV data in the default CF is preserved and readable via kvstore_vec_get. After this call, kvstore_vec_search returns KVVEC_INDEX_DROPPED. Sidecar files are removed.

kvstore_vec_drop_index(vs);    /* index gone, KV intact */

void *v = NULL; int n = 0;
kvstore_vec_get(vs, "doc:1", 5, &v, &n);  /* still works */
snkv_free(v);

Build

The vector layer requires g++ (C++17) to compile the usearch HNSW core. The default make / make test targets use pure gcc and are unaffected.

# Build libsnkv_vec.a (core + usearch objects)
make vector

# Build and run the comprehensive example
make vector-examples
make run-vector-examples

# Build and run the test suite (150 tests)
make test-vector

Internal storage (5 Column Families)

_snkv_vec_key → float32[dim] — stored vector
_snkv_vec_idk_key → uint64 label (8B big-endian) — maps key to HNSW label
_snkv_vec_idi_uint64 label → key — reverse lookup used during search
_snkv_vec_meta_config keys: ndim, metric, connectivity, expansion_add, expansion_search, dtype, next_id
_snkv_vec_tags_key → JSON bytes — created lazily on first metadata write

Sidecar files

{path}.usearchusearch HNSW binary graph — saved on close, loaded on open
{path}.usearch.nid8-byte big-endian next-id stamp — used to detect stale sidecars
Sidecar persistence is disabled for encrypted stores and in-memory stores (zPath=NULL). If the stamp in .usearch.nid does not match the stored next_id the sidecar is discarded and the index is rebuilt from the CFs.

Installation

# Install from PyPI
pip install snkv

# Or build from source
cd python
python3 setup.py build_ext --inplace

Quick Start

from snkv import KVStore

with KVStore("mydb.db") as db:
    db["hello"] = "world"
    print(db["hello"].decode())      # world
    print(db.get("missing"))         # None

    for key, value in db:
        print(key, value)              # b'hello' b'world'

Constants

Journal Mode

ConstantDescription
JOURNAL_WALWrite-Ahead Logging — concurrent readers (default).
JOURNAL_DELETERollback journal mode.

Sync Level

ConstantDescription
SYNC_OFFNo fsync — fastest, not crash-safe.
SYNC_NORMALSurvives process crash (default).
SYNC_FULLfsync on every commit — strongest durability.

Checkpoint Mode

ConstantDescription
CHECKPOINT_PASSIVENon-blocking copy (default).
CHECKPOINT_FULLWait for all readers, then copy all frames.
CHECKPOINT_RESTARTLike FULL, then reset WAL write position.
CHECKPOINT_TRUNCATELike RESTART, then truncate WAL to zero bytes.

TTL

ConstantValueDescription
NO_TTL-1Returned by ttl() when key has no expiry.

Exceptions

snkv.Error           ← base class for all SNKV errors
├── snkv.NotFoundError  (also subclass of KeyError)
├── snkv.BusyError
├── snkv.LockedError
├── snkv.ReadOnlyError
├── snkv.CorruptError
└── snkv.AuthError
ExceptionRaised when
ErrorGeneric SNKV error (base class).
NotFoundErrorKey does not exist; also a KeyError.
BusyErrorDatabase locked and busy_timeout expired.
LockedErrorWrite lock conflict with a concurrent transaction.
ReadOnlyErrorWrite attempted on a read-only store.
CorruptErrorIntegrity check detected corruption.
AuthErrorWrong password supplied to an encrypted store, or encrypted store opened without a password via KVStore().

KVStore

The main entry point. Opens or creates a key-value store at a given path. Always use as a context manager to ensure proper cleanup. Raises AuthError if the file is an encrypted store — use KVStore.open_encrypted() instead.

Opening a Store — KVStore(path, *, **config)

KVStore( path = None, # str path or None for in-memory journal_mode = JOURNAL_WAL, sync_level = SYNC_NORMAL, cache_size = 2000, # pages (~8 MB default) page_size = 4096, # bytes; new databases only busy_timeout = 0, # ms; 0 = fail immediately wal_size_limit= 0, # auto-checkpoint every N commits; 0 = off read_only = 0 # 1 = read-only )
# Minimal
db = KVStore("mydb.db")

# In-memory
db = KVStore()    # or KVStore(None)

# Fully configured
db = KVStore(
    "mydb.db",
    journal_mode=JOURNAL_WAL,
    sync_level=SYNC_NORMAL,
    cache_size=4000,
    busy_timeout=5000,
    wal_size_limit=200,
)

# Context manager (recommended)
with KVStore("mydb.db") as db:
    db["key"] = "value"

Core Operations

put(key, value, ttl=None) → None

Insert or overwrite a key-value pair. ttl is seconds until expiry (int or float); None means no expiry. Both key and value accept str, bytes, bytearray, or memoryview.

db.put("user:1", b"\x01\x02\x03")
db.put("session", "tok", ttl=3600)  # expires in 1 hour
db.put("cache",   data,   ttl=0.5)  # half a second

get(key, default=None) → bytes | None

Return the stored value as bytes, or default if the key does not exist or has expired. Never raises NotFoundError — use db[key] for that.

val = db.get("user:1")         # bytes or None
val = db.get("missing", b"") # b'' if not found

delete(key) → None

Delete a key. Raises NotFoundError (KeyError) if the key does not exist.

exists(key) → bool

Return True if the key exists, without fetching the value.

TTL / Key Expiry

ttl(key) → float | None

Return valueMeaning
Positive floatSeconds remaining.
0.0Key just expired (lazy delete performed).
NoneKey exists but has no expiry.
raises NotFoundErrorKey does not exist.
db.put("session", "tok", ttl=3600)
print(db.ttl("session"))   # e.g. 3599.97
print(db.ttl("perm"))      # None (no TTL)

purge_expired() → int

Scan the TTL index and delete all expired keys in a single transaction. Returns the count of keys deleted. O(expired keys).

n = db.purge_expired()
print(f"Removed {n} expired keys")

Conditional Insert / Bulk Clear / Key Count

put_if_absent(key, value, ttl=None) → bool

Atomically insert key only when it is absent (or has expired). Returns True if inserted, False if already present. Safe for distributed locks and deduplication.

# Distributed lock with auto-release
inserted = db.put_if_absent(b"lock:job-1", b"worker-A", ttl=30)
if inserted:
    run_job()   # only one worker reaches here

# Deduplication
if db.put_if_absent(b"msg:001", b"payload"):
    process()   # first write wins

clear() → None

Remove every key-value pair from the default column family in a single atomic write transaction. TTL index entries are cleared atomically. Runs in O(pages) — much faster than iterating and deleting individually. Close all iterators before calling.

db.clear()   # truncate default CF; all keys gone
db.put(b"fresh", b"start")   # normal inserts work immediately after

count() → int

Return the number of entries in the default column family. Reads page-level nCell headers via sqlite3BtreeCount — O(pages), not O(rows). Expired-but-not-yet-purged keys are included; call purge_expired() first for an exact live count.

n = db.count()   # e.g. 10000
db.purge_expired()
n = db.count()   # exact live count

Dict-like Interface

SyntaxEquivalentNotes
db["key"]getRaises NotFoundError on miss/expiry.
db["key"] = "val"putNo TTL.
db["key", ttl] = "val"put(ttl=...)TTL in seconds.
del db["key"]deleteRaises NotFoundError on miss.
"key" in dbexists
for k, v in dbiterator()Yields (bytes, bytes).
db["session:abc"] = "active"
db["token:xyz", 60] = "bearer-abc"   # expires in 60 s
print(db["session:abc"])               # b'active'
del db["session:abc"]

Transactions

begin(write=False) / commit() / rollback()

ACID transactions for batching operations atomically. Without an explicit transaction, each individual put/delete auto-commits.

db.begin(write=True)
try:
    db["a"] = "1"
    db["b"] = "2"
    db.commit()
except Exception:
    db.rollback()
    raise

Column Families

db.create_column_family(name) → ColumnFamily db.open_column_family(name) → ColumnFamily db.default_column_family() → ColumnFamily db.list_column_families() → list[str] db.drop_column_family(name)
with db.create_column_family("users") as users:
    users["alice"] = b"admin"

with db.open_column_family("users") as users:
    print(users["alice"])  # b'admin'

names = db.list_column_families()  # ["users", ...]
db.drop_column_family("users")

Iterators

db.iterator(*, reverse=False, prefix=None) → Iterator db.prefix_iterator(prefix) → Iterator db.reverse_iterator() → Iterator db.reverse_prefix_iterator(prefix) → Iterator
# Forward scan
for key, value in db.iterator():
    print(key.decode(), value.decode())

# Reverse scan
for key, value in db.iterator(reverse=True):
    print(key.decode(), value.decode())

# Prefix, ascending
for key, value in db.iterator(prefix="user:"):
    print(key, value)

# Prefix, descending
for key, value in db.iterator(prefix="user:", reverse=True):
    print(key, value)

Maintenance

sync() / vacuum(n_pages=0) / integrity_check() / checkpoint(mode) / stats() / stats_reset()

db.sync() # flush pending writes to disk db.vacuum(n_pages=0) # reclaim unused pages (0 = all) db.integrity_check() # raises CorruptError on failure nlog, nckpt = db.checkpoint(mode) # returns (nLog, nCkpt) tuple s = db.stats() # dict with 12 counters (see below) db.stats_reset() # zero all cumulative counters; db_pages is unaffected print(db.errmsg) # last error message string (property)

stats() returns a dict with these keys:

KeyDescription
putsTotal put operations (includes TTL puts and put_if_absent).
getsTotal get operations (includes TTL gets).
deletesTotal delete operations.
iterationsTotal iterators created (each iterator(), prefix_iterator(), etc. call counts as one).
errorsTotal non-NOTFOUND error returns.
bytes_readTotal value bytes returned by get operations.
bytes_writtenTotal key+value bytes written by put operations.
wal_commitsTotal WAL write transaction commits.
checkpointsTotal WAL checkpoint operations.
ttl_expiredTotal keys lazily deleted on access due to TTL expiry.
ttl_purgedTotal keys deleted by purge_expired().
db_pagesCurrent live database page count (always up-to-date; not reset by stats_reset()).
import snkv
from snkv import CHECKPOINT_TRUNCATE, CorruptError

# Vacuum after bulk deletes
db.vacuum()

# Checkpoint and reclaim WAL space
nlog, nckpt = db.checkpoint(CHECKPOINT_TRUNCATE)
print(f"WAL: {nlog} frames, {nckpt} checkpointed")

try:
    db.integrity_check()
except CorruptError as e:
    print(f"Corruption: {e}")

st = db.stats()
print(st["puts"], st["gets"], st["bytes_written"], st["db_pages"])
db.stats_reset()   # counters zeroed; db_pages still live

Lifecycle

close() / context manager

Close the store and release all resources. All column family and iterator handles must be closed first. Always prefer the context manager form.

with KVStore("mydb.db") as db:
    ...
# db.close() called automatically

ColumnFamily

A logical namespace within a KVStore. Obtained via db.create_column_family(), db.open_column_family(), or db.default_column_family(). Always use as a context manager.

Core Operations

cf.put(key, value, ttl=None) # insert/overwrite; ttl in seconds cf.get(key, default=None) # returns bytes or default cf.delete(key) # raises NotFoundError on miss cf.exists(key) → bool
with db.open_column_family("users") as cf:
    cf.put("alice", b"admin")
    cf.put("token", b"xyz", ttl=300)   # 5 minutes
    role = cf.get("alice")            # b'admin'
    cf.delete("alice")

CF TTL

cf.ttl(key) → float | None # remaining seconds or None (no expiry) cf.purge_expired() → int # delete expired keys in this CF only
remaining = cf.ttl("token")   # e.g. 284.3
n = cf.purge_expired()
print(f"Cleaned {n} entries")

CF Conditional Insert / Bulk Clear / Key Count

cf.put_if_absent(key, value, ttl=None) → bool # True if inserted cf.clear() # truncate this CF only; O(pages) cf.count() → int # entry count; O(pages)
with db.open_column_family("dedup") as cf:
    # First write wins — safe deduplication
    if cf.put_if_absent(b"msg:001", b"hello"):
        process()

    print(cf.count())   # entry count for this CF only
    cf.clear()          # other CFs unaffected
    print(cf.count())   # 0

CF Dict Interface

SyntaxNotes
cf["key"]Raises NotFoundError on miss/expiry.
cf["key"] = "val"No TTL.
cf["key", ttl] = "val"TTL in seconds.
del cf["key"]Raises NotFoundError on miss.
"key" in cfExistence check.
for k, v in cfForward iteration over all keys.
cf["alice"] = "admin"
cf["session:u42", 300] = "logged-in"
print(cf["alice"])           # b'admin'
del cf["alice"]

CF Iterators

cf.iterator(*, reverse=False, prefix=None) → Iterator cf.prefix_iterator(prefix) → Iterator cf.reverse_iterator() → Iterator cf.reverse_prefix_iterator(prefix) → Iterator
for key, value in cf.iterator(prefix="user:", reverse=True):
    print(key, value)

Iterator

Ordered cursor over key-value pairs. Forward iterators yield keys ascending; reverse iterators yield keys descending. Each iteration step yields a (bytes, bytes) tuple.

Python Iterator Protocol

The most common usage — iterate with a for loop or context manager:

# Simple forward
for key, value in db.iterator():
    print(key.decode(), "->", value.decode())

# Reverse with context manager (auto-closes)
with db.iterator(reverse=True) as it:
    for key, value in it:
        print(key, value)

Manual Control

it.first() # seek to first key (forward iterator) it.last() # seek to last key (reverse iterator) it.seek(key) → self # O(log N) positional seek; returns self for chaining it.next() # advance forward it.prev() # advance backward it.eof # True when no more keys it.key # current key as bytes it.value # current value as bytes it.item() # (key, value) tuple it.close()
# Manual forward
it = db.iterator()
it.first()
while not it.eof:
    print(it.key.decode(), it.value.decode())
    it.next()
it.close()

# Manual reverse
it = db.reverse_iterator()
it.last()
while not it.eof:
    k, v = it.item()
    print(k, v)
    it.prev()
it.close()

# seek — jump to any position in O(log N)
with db.iterator() as it:
    it.seek(b"user:carol")   # forward: first key >= target
    while not it.eof:
        print(it.key)
        it.next()

# seek on a prefix iterator — boundary still enforced
with db.iterator(prefix=b"user:") as it:
    it.seek(b"user:m")   # skips to "user:m..." within prefix
    while not it.eof:
        print(it.key)
        it.next()

# seek returns self — chainable
key = db.iterator().seek(b"target").key

Type Notes

Input typeBehaviour
strEncoded to UTF-8 bytes automatically.
bytesPassed through as-is.
bytearrayConverted to bytes.
memoryviewConverted to bytes.

Return values are always bytes.

Thread safety: The underlying C store is mutex-protected. Each Python method acquires a mutex and releases the GIL for blocking I/O — safe to share a KVStore across threads. Column family and iterator handles are not independently thread-safe.

Encryption

Transparent per-value encryption using XChaCha20-Poly1305 with Argon2id key derivation. All existing methods — put, get, delete, iterators, TTL, column families — work without modification on an encrypted store.

KVStore.open_encrypted / is_encrypted / reencrypt / remove_encryption

KVStore.open_encrypted(path, password, **kwargs) → KVStore db.is_encrypted() → bool db.reencrypt(new_password) db.remove_encryption()

open_encrypted is a class method that opens or creates an encrypted store. password may be bytes or str (UTF-8 encoded). If the file is a plain store with existing data, all values are encrypted in-place and the store becomes encrypted — existing data is preserved. Raises AuthError only on a wrong password for an already-encrypted store.

from snkv import KVStore, AuthError

# Create / open encrypted store
with KVStore.open_encrypted("mydb.db", b"hunter2") as db:
    db[b"secret"] = b"classified"
    print(db.is_encrypted())   # True
    print(db[b"secret"])        # b"classified" — transparent decrypt

# Wrong password raises AuthError
try:
    KVStore.open_encrypted("mydb.db", b"wrong")
except AuthError:
    print("bad password")

# Change password in-place (re-encrypts all values atomically)
with KVStore.open_encrypted("mydb.db", b"hunter2") as db:
    db.reencrypt(b"new-strong-pass")

# Remove encryption — store becomes plain
with KVStore.open_encrypted("mydb.db", b"new-strong-pass") as db:
    db.remove_encryption()
with KVStore("mydb.db") as db:   # plain open works now
    print(db[b"secret"])

# open_encrypted on a plain store — encrypts it in-place
with KVStore.open_encrypted("mydb.db", b"fresh-pass") as db:
    print(db.is_encrypted())   # True — store is now encrypted
    print(db[b"secret"])        # b"classified" — data preserved
AuthError is a subclass of snkv.Error raised when a wrong password is supplied to an already-encrypted store. Calling open_encrypted on a plain store encrypts it in-place instead of raising AuthError.

VectorStore

SNKV key-value store with an integrated usearch HNSW vector index. All vector data lives inside the same .db file using dedicated column families.

Index persistence: For unencrypted, file-backed stores the in-memory index is saved to {path}.usearch on close() and reloaded on the next open — skipping the O(n×d) rebuild. If the sidecar is absent, stale, or corrupt it is silently discarded and the index is rebuilt from the column families. Encrypted stores and in-memory stores always rebuild from column families (a plaintext sidecar would defeat encryption).

Requires the vector extra: pip install snkv[vector]

Installation

# installs snkv + usearch>=2.9 + numpy>=1.21
pip install snkv[vector]

Opening a VectorStore

VectorStore(path, dim, *, space, connectivity, expansion_add, expansion_search, dtype, password, **kv_kwargs)

from snkv.vector import VectorStore VectorStore(path, dim, *, space="l2", connectivity=16, expansion_add=128, expansion_search=None, dtype="f32", password=None, **kv_kwargs)
ParameterDefaultDescription
pathPath to .db file. None for in-memory.
dimVector dimension. Fixed for the lifetime of the store.
space"l2"Distance metric: "l2" (squared L2), "cosine", or "ip" (inner product).
connectivity16HNSW M parameter. Must be ≥ 1.
expansion_add128HNSW expansion during index build. Must be ≥ 1.
expansion_searchNoneNone restores the stored value (falls back to 64 for new stores). Explicit value overrides stored.
dtype"f32"In-memory HNSW index precision: "f32" (default), "f16" (half RAM), or "i8" (quarter RAM). On-disk storage in _snkv_vec_ is always float32 regardless of dtype. Lower precision reduces memory at a small recall cost — see Quantization below.
passwordNonebytes or str. Opens/creates an encrypted store. Disables sidecar — index is always rebuilt from encrypted CFs.
from snkv.vector import VectorStore
import numpy as np

# Plain store
with VectorStore("store.db", dim=128, space="cosine") as vs:
    vs.vector_put(b"doc:1", b"hello world", np.random.rand(128).astype("f4"))

# Encrypted store
with VectorStore("store.db", dim=128, password=b"secret") as vs:
    vs.vector_put(b"doc:1", b"hello world", np.random.rand(128).astype("f4"))

# Quantized store — half RAM for the in-memory HNSW index
with VectorStore("store.db", dim=768, space="cosine", dtype="f16") as vs:
    vs.vector_put(b"doc:1", b"hello world", np.random.rand(768).astype("f4"))

Quantization: dtype controls only the in-memory HNSW graph precision — on-disk vectors in _snkv_vec_ are always float32. Memory per vector in the index: f32 = dim × 4 bytes, f16 = dim × 2 bytes, i8 = dim × 1 byte. For 1 M vectors at dim=768: f32 ≈ 3 GB, f16 ≈ 1.5 GB, i8 ≈ 768 MB. dtype is immutable — validated against the stored value on reopen.

Writing Vectors

vector_put(key, value, vector, *, ttl=None, metadata=None) → None

Store a key/value pair and add its vector to the HNSW index atomically. The SNKV write (all column families) is one transaction; the in-memory usearch update happens after commit. On overwrite the old usearch entry is removed and replaced.

ParameterDescription
keybytes or str
valuebytes or str — arbitrary payload stored in the default CF
vectorarray-like, shape (dim,) — stored as float32 on disk
ttlFinite positive float — seconds until expiry. None = no expiry.
metadatadict or None. None = preserve existing. {} = clear. {...} = write new. Must be JSON-serializable.
vs.vector_put(b"doc:1", b"hello", np.random.rand(128).astype("f4"))
vs.vector_put(b"doc:2", b"world", np.random.rand(128).astype("f4"),
              ttl=3600, metadata={"category": "news", "score": 0.9})

vector_put_batch(items, *, ttl=None) → None

Insert or update multiple items in a single SNKV transaction with one bulk index.add call. 5–20× faster than N individual vector_put calls for bulk loads. Duplicate keys in the batch are deduplicated — last occurrence wins. If any item has an invalid vector shape the entire batch rolls back.

ParameterDescription
itemsIterable of (key, value, vector) or (key, value, vector, metadata)
ttlFinite positive float — uniform expiry applied to all items.
vs.vector_put_batch([
    (b"a", b"val-a", np.random.rand(128).astype("f4")),
    (b"b", b"val-b", np.random.rand(128).astype("f4"), {"tag": "x"}),
])

Reading Vectors & Values

vector_get(key) → np.ndarray

Return the stored vector as np.ndarray(dim,) float32. Raises NotFoundError if no vector is stored for the key. Raises VectorIndexError if the index has been dropped.

get(key, default=None) → bytes | None

Return value bytes from the default CF, or default if missing or expired. Identical to KVStore.get.

get_metadata(key) → dict | None

Return the stored metadata dict, or None if the key has no metadata or the tags CF has never been created. Returns None silently on corrupted JSON.

vec  = vs.vector_get(b"doc:1")    # np.ndarray(128,)
val  = vs.get(b"doc:1")            # b"hello"
meta = vs.get_metadata(b"doc:2")  # {"category": "news", "score": 0.9}

Deleting

delete(key) → None

Delete key from the KV store and HNSW index atomically. Raises NotFoundError if the key does not exist or has expired. If the vector index has been dropped, falls back to a plain KV delete.

vs.delete(b"doc:1")

TTL

Vectors stored with ttl= are lazily expired: expired keys are skipped transparently in search(), search_keys(), and get(). Call vector_purge_expired() to reclaim disk space and remove them from the in-memory index.

# Store with 1-hour TTL
vs.vector_put(b"session", b"data", vec, ttl=3600)

# Expired keys are silently skipped in search
results = vs.search(q, top_k=5)  # won't include b"session" after expiry

# Bulk-purge expired vectors (keeps in-memory index in sync)
n = vs.vector_purge_expired()
print(f"purged {n} vectors")

Stats & Info

vector_stats() → dict

Return full index configuration and runtime state.

KeyDescription
dimVector dimension
spaceDistance metric
dtypeIn-memory index precision
connectivityHNSW M parameter
expansion_addHNSW build expansion
expansion_searchHNSW search expansion
countActive vectors in usearch index
capacityAllocated usearch capacity
fill_ratiocount / capacity
vec_cf_countEntries in _snkv_vec_ CF (may include expired)
has_metadataTrue if metadata CF exists
sidecar_enabledTrue for unencrypted file-backed stores; False for encrypted or in-memory

vector_info() → dict

Subset of vector_stats(): dim, space, dtype, count, connectivity, expansion_add, expansion_search.

__len__() → int

Active vector count. Same as vector_stats()["count"].

print(len(vs))              # active vector count
print(vs.vector_stats())   # full dict

Maintenance

vector_purge_expired() → int

Scan all vector entries, delete those whose KV value has expired, remove them from the HNSW index, and return the count deleted. Always call this instead of KVStore.purge_expired() on a VectorStore to keep the vector column families in sync.

n = vs.vector_purge_expired()
print(f"removed {n} expired vectors")

drop_vector_index

drop_vector_index() → None

Drop all five internal vector column families (_snkv_vec_, _snkv_vec_idk_, _snkv_vec_idi_, _snkv_vec_meta_, _snkv_vec_tags_) permanently. KV data in the default CF is preserved and accessible via get(). The sidecar file ({path}.usearch) is also deleted. After this call, search() and vector_put() raise VectorIndexError.

vs.drop_vector_index()
val = vs.get(b"doc:1")   # still works — KV data intact

Dict Interface

Standard dict operations are forwarded to the plain KV layer. They do not interact with the vector index.

SyntaxEquivalent
vs[key]get(key), raises NotFoundError if missing
vs[key] = valuePlain KV put — does not update vector index
del vs[key]delete(key) — removes from both KV and vector index
key in vsTrue if key exists in default CF
len(vs)Active vector count

Lifecycle

close() / context manager

Close all column family handles and the underlying KVStore. For unencrypted file-backed stores, saves the in-memory usearch index to {path}.usearch so the next open can skip the rebuild. Use as a context manager (with VectorStore(...) as vs:) for automatic cleanup.

VectorIndexError

Raised when the usearch index is not initialised or has been dropped. Subclass of Exception. Import from snkv.vector.

from snkv.vector import VectorStore, VectorIndexError, SearchResult

with VectorStore("store.db", dim=128) as vs:
    vs.vector_put(b"k", b"v", np.zeros(128, dtype="f4"))
    results = vs.search(np.zeros(128, dtype="f4"), top_k=1)
    print(results[0].key, results[0].distance)