λ Lytecache

Embedded key-value store

Redis-like caching. Zero infrastructure.

One local SQLite file stands in for the server. set, get, TTLs, atomic counters, eviction, and process-safe locks — implemented natively in whichever language you already write, all reading and writing the very same file.

Python Java Node.js Go PHP + more coming
Install
pip install lytecache
implementation 'io.github.lytecache:lytecache:0.2.0'
<dependency>
  <groupId>io.github.lytecache</groupId>
  <artifactId>lytecache</artifactId>
  <version>0.2.0</version>
</dependency>
npm install lytecache
go get github.com/lytecache/lytecache-go
composer require lytecache/lytecache
Then use it
from lytecache import LyteCache

cache = LyteCache()
cache.set("hits", 0)
cache.incr("hits")
import io.lytecache.LyteCache;

try (LyteCache cache = new LyteCache()) {
    cache.set("hits", 0L);
    cache.incr("hits");
}
import { LyteCache } from "lytecache";

const cache = new LyteCache();
cache.set("hits", 0);
cache.incr("hits");
cache, _ := lytecache.New()
defer cache.Close()

cache.Set("hits", 0)
cache.Incr("hits", 1)
use Lytecache\LyteCache;

$cache = new LyteCache();
$cache->set("hits", 0);
$cache->incr("hits");

Zero config

The first write creates the file, directories, and schema. No init step.

One file format

Every implementation reads and writes the identical schema — even the same counters.

Atomic under load

Counters and locks are single SQL statements, correct across threads and processes.

Nothing to run

WAL-mode SQLite instead of a daemon. Ships inside your process.

Overview

Quickstart

Add the dependency, then use it. No path, no server, no migration step.

from lytecache import LyteCache

cache = LyteCache()                     # no path, no setup -- just works
cache.set("user:42", {"name": "Samson"}, ttl=300)
cache.get("user:42")                    # {"name": "Samson"}
cache.incr("hits")                      # 1
import io.lytecache.LyteCache;
    import java.time.Duration;

    try (LyteCache cache = new LyteCache()) {
        cache.set("user:42", "Samson", Duration.ofMinutes(5));
        cache.getString("user:42");          // "Samson"
        cache.incr("hits");                  // 1
}
import { LyteCache } from "lytecache";

const cache = new LyteCache();               // no path, no setup -- just works
cache.set("user:42", { name: "Samson" }, { ttl: 300 });
cache.get("user:42");                        // { name: "Samson" }
cache.incr("hits");                          // 1
cache, _ := lytecache.New()          // no path, no setup -- just works
defer cache.Close()

cache.Set("user:42", map[string]any{"name": "Samson"}, lytecache.TTL(5*time.Minute))
var user map[string]any
cache.Get("user:42", &user)                // {"name": "Samson"}
cache.Incr("hits", 1)                        // 1
use Lytecache\LyteCache;

$cache = new LyteCache();               // no path, no setup -- just works
$cache->set("user:42", ["name" => "Samson"], ttl: 300);
$cache->get("user:42");                 // ["name" => "Samson"]
$cache->incr("hits");                   // 1

All five are disposable: with LyteCache() as cache: in Python, try (LyteCache cache = new LyteCache()) in Java, using cache = new LyteCache() in Node.js (or call cache[Symbol.dispose]() directly), defer cache.Close() in Go, and PHP's __destruct() flushes and closes automatically even if you never call $cache->close() yourself. Closing flushes any buffered state and stops the background sweeper cleanly (PHP has no background sweeper -- see TTL & expiration).

Overview

Where is my data?

By default, every implementation resolves the exact same file for the same project — so a Python, Java, Node.js, Go, or PHP process started from the same directory all share one cache automatically.

<platform cache dir>/lytecache/<project-id>.db
Platform Default location
Linux $XDG_CACHE_HOME/lytecache/<project-id>.db, or ~/.cache/lytecache/<project-id>.db
macOS ~/Library/Caches/lytecache/<project-id>.db
Windows %LOCALAPPDATA%\lytecache\<project-id>.db

<project-id> is the first 12 hex characters of the SHA-256 hash of your current working directory's resolved, absolute path — an identical derivation in every implementation, so every project gets its own file and nothing is left behind in your repo. (See the glossary if "SHA-256" is new to you.)

Overriding it

  • Pass an explicit path — LyteCache("/data/cache.db") in Python, .path(Path.of("/data/cache.db")) on the Java builder, new LyteCache({ path: "/data/cache.db" }) in Node.js, lytecache.WithPath("/data/cache.db") in Go, new LyteCache(path: "/data/cache.db") in PHP.
  • Set LYTECACHE_PATH=/data/cache.db in the environment — takes priority over the default in every language.

Introspect it programmatically: LyteCache.default_path() / cache.path in Python, LyteCache.defaultPath() / cache.path() in Java, LyteCache.defaultPath() / cache.path in Node.js, lytecache.DefaultPath() / cache.Path() in Go, LyteCache::defaultPath() / $cache->path() in PHP. The file is never a mystery.

Laravel is the one exception: its cache driver defaults to storage_path('framework/cache/lytecache.db') instead of the platform cache directory above, since that's where a Laravel app expects its cache files to live — configurable in config/cache.php's stores.lytecache.path.

Guide

TTL & expiration

Expiration applies identically to every value type — strings, numbers, dataclasses, dicts, POJOs, records. There's nothing special about storing an object.

Watch the units

Python's, Node.js's, and PHP's ttl are all plain numbers, in seconds (a float in Python and PHP, a number in Node). ttl=5000 / { ttl: 5000 } means "expire in 5000 seconds" (~83 minutes), not 5 seconds — the most common reason a key seems to "never expire." Java's and Go's are explicit (a Java Duration, a Go time.Duration), so there's no unit ambiguity there.

cache.set("session", {"user_id": 42}, ttl=300)   # expires in 300s (5 min)
cache.set("otp", "123456", ttl=0.5)              # expires in 500ms

cache.ttl("session")        # seconds remaining, e.g. 298.7
cache.ttl("permanent_key")  # -1 if no TTL was set
cache.ttl("missing")        # None if the key doesn't exist

cache.expire("session", 60)   # overwrite the TTL: now expires in 60s
cache.persist("session")      # remove the TTL entirely
cache.touch("session", 300)   # sliding expiration: refresh back to 300s
cache.set("session", "42", Duration.ofMinutes(5));
cache.set("otp", "123456", Duration.ofMillis(500));

cache.ttl("session");     // Duration remaining
cache.ttl("permanent");   // Duration.ofSeconds(-1) if no TTL
cache.ttl("missing");     // null if the key doesn't exist

cache.expire("session", Duration.ofSeconds(60));  // overwrite the TTL
cache.persist("session");                          // remove the TTL entirely
cache.touch("session", Duration.ofMinutes(5));     // sliding expiration
cache.set("session", { userId: 42 }, { ttl: 300 });  // expires in 300s (5 min)
cache.set("otp", "123456", { ttl: 0.5 });            // expires in 500ms

cache.ttl("session");        // seconds remaining, e.g. 298.7
cache.ttl("permanentKey");   // -1 if no TTL was set
cache.ttl("missing");        // undefined if the key doesn't exist

cache.expire("session", 60);   // overwrite the TTL: now expires in 60s
cache.persist("session");      // remove the TTL entirely
cache.touch("session", 300);   // sliding expiration: refresh back to 300s
cache.Set("session", 42, lytecache.TTL(5*time.Minute))   // expires in 5 min
cache.Set("otp", "123456", lytecache.TTL(500*time.Millisecond))

_, hasExpiry, found, _ := cache.TTLOf("session")   // remaining time.Duration
_, hasExpiry, found, _ = cache.TTLOf("permanent")  // hasExpiry=false if no TTL
_, _, found, _ = cache.TTLOf("missing")            // found=false if the key doesn't exist

cache.Expire("session", 60*time.Second)  // overwrite the TTL
cache.Persist("session")                 // remove the TTL entirely
cache.Touch("session", 5*time.Minute)    // sliding expiration: refresh back to 5 min
$cache->set("session", ["user_id" => 42], ttl: 300);   // expires in 300s (5 min)
$cache->set("otp", "123456", ttl: 0.5);                // expires in 500ms

$cache->ttl("session");        // seconds remaining, e.g. 298.7
$cache->ttl("permanent_key");  // -1 if no TTL was set
$cache->ttl("missing");        // null if the key doesn't exist

$cache->expire("session", 60);   // overwrite the TTL: now expires in 60s
$cache->persist("session");      // remove the TTL entirely
$cache->touch("session", 300);   // sliding expiration: refresh back to 300s

Lazy and active expiration

Expiration is enforced two ways, in every implementation:

  • Lazily, on every read (get / exists / getAll / getMany / TTLOf): an expired row is treated as a miss and deleted on the spot, even with the background sweeper disabled.
  • Actively, by a background sweeper (a thread in Python/Java, a setInterval timer in Node.js, a goroutine in Go) that runs every sweep_interval / sweepInterval / WithSweepInterval (default 60s) and deletes expired rows in batches, so disk isn't held by dead keys even if nothing ever reads them again. PHP has no background threads, so instead it runs bounded maintenance passes opportunistically (roughly every 100 operations, throttled by sweepInterval) — Laravel apps should also schedule the lytecache:maintain artisan command so a quiet cache still gets swept.

If you inspect the .db file directly (a SQLite browser, say) moments after a key expires but before the next sweep tick, the row may still be physically present — that's expected. get()/exists() already treat it as gone. Lower the sweep interval, or call vacuum(), if you need the file to shrink sooner.

Guide

Flush & delete

cache.delete("key1", "key2")   # delete specific keys; returns how many existed
cache.flush()                  # delete EVERYTHING in the current namespace
cache.delete("key1", "key2");   // delete specific keys; returns how many existed
cache.flush();                  // delete EVERYTHING in the current namespace
cache.delete("key1", "key2");   // delete specific keys; returns how many existed
cache.flush();                  // delete EVERYTHING in the current namespace
cache.Delete("key1", "key2")   // delete specific keys; returns how many existed
cache.Flush()                  // delete EVERYTHING in the current namespace
$cache->delete("key1", "key2");   // delete specific keys; returns how many existed
$cache->flush();                  // delete EVERYTHING in the current namespace

flush() takes no key or pattern argument by design — it always clears the entire namespace (the default namespace, unless you configured a different one). To clear a subset, delete by key or by pattern instead:

for key in list(cache.keys("session:*")):
cache.delete(key)
cache.keys("session:*").forEach(cache::delete);
for (const key of cache.keys("session:*")) cache.delete(key);
for key, _ := range cache.Keys("session:*") {
    cache.Delete(key)
}
foreach ($cache->keys("session:*") as $key) {
    $cache->delete($key);
}

Namespaces

If two LyteCache instances point at the same file but different namespaces, they're fully isolated — flush() on one never touches the other's keys. That's the usual reason flush() looks like it "didn't delete everything": check you're flushing the same namespace you wrote to.

Overview

When to use lytecache

Good fit

  • Single-node apps (or single-machine, multi-process apps) that want caching, counters, or TTLs with zero infrastructure
  • Scripts, CLIs, notebooks, small web services, background jobs, test fixtures
  • A cache that survives process restarts without a separate daemon
  • Multi-process coordination via the process-safe distributed lock
  • Mixed-language systems — Python, Java, Node.js, Go, and PHP processes can all share one cache file
  • Laravel apps that want Cache::remember() and Cache::lock() with zero infrastructure — swap in the driver by changing one config line

Not a good fit

  • A cache shared live across multiple servers/hosts — SQLite is a local file, not a network service. Use Redis/Memcached.
  • Heavy concurrent write throughput from many processes — SQLite's single-writer model will serialize writes and become a bottleneck
  • Pub/sub, streams, or other Redis data structures beyond key-value + counters
  • Complex queries over cached data — use a real database

Guide

Configuration

Every option has a sane default; nothing here is required to get started.

LyteCache(
    path=None,             # explicit file path; default: LyteCache.default_path()
    namespace="default",   # logical partition within the database file
    max_keys=None,         # evict when the namespace exceeds this many keys
    max_bytes=None,        # evict when the namespace exceeds this many bytes
    eviction="lru",        # "lru" | "ttl" | "random" | "noeviction"
    sweep_interval=60.0,   # seconds between maintenance passes; None disables
                           # the thread and sweeps opportunistically instead
    serializer="auto",     # "auto" | "json" (strict) | "pickle"
    strict=False,          # True: raise on internal read errors
    allow_pickle=False,    # "auto" mode only: allow reading pickled values
)
LyteCache cache = LyteCache.builder()
.path(Path.of("/data/my-cache.db"))    // optional; platform cache dir if omitted
.namespace("sessions")                  // isolate multiple caches in one file
.maxKeys(100_000)                       // evict when exceeded (default: 1M)
.maxBytes(256L * 1024 * 1024)           // evict when exceeded (default: 1 GB)
.eviction(Eviction.LRU)                 // LRU (default), TTL, RANDOM, NOEVICTION
.sweepInterval(Duration.ofSeconds(60))  // background cleanup frequency
.strict(false)                          // on read error: log+miss, or throw
.build();
new LyteCache({
  path: undefined,       // explicit file path; default: LyteCache.defaultPath()
  namespace: "default",  // logical partition within the database file
  maxKeys: undefined,    // evict when the namespace exceeds this many keys
  maxBytes: undefined,   // evict when the namespace exceeds this many bytes
  eviction: "lru",       // "lru" | "ttl" | "random" | "noeviction"
  sweepInterval: 60,     // seconds between maintenance passes; null disables
                         // the timer and sweeps opportunistically instead
  strict: false,         // true: throw on internal read errors instead of
                         // degrading to a miss
  logger: console.warn,  // called on a non-strict degraded read
});
cache, err := lytecache.New(
    lytecache.WithPath("/data/cache.db"),        // optional; default: lytecache.DefaultPath()
    lytecache.WithNamespace("sessions"),          // isolate multiple caches in one file
    lytecache.WithMaxKeys(100_000),               // evict when exceeded (default: no limit)
    lytecache.WithMaxBytes(256<<20),              // evict when exceeded (default: no limit)
    lytecache.WithEviction(lytecache.LRU),        // LRU (default), TTLPolicy, Random, NoEviction
    lytecache.WithSweepInterval(60*time.Second),  // 0 disables the goroutine -> opportunistic mode
    lytecache.WithStrict(false),                  // true: return read errors instead of a miss
)
new LyteCache(
    path: null,               // explicit file path; default: LyteCache::defaultPath()
    namespace: "default",     // logical partition within the database file
    maxKeys: null,            // evict when the namespace exceeds this many keys
    maxBytes: null,           // evict when the namespace exceeds this many bytes
    eviction: Eviction::LRU,  // LRU (default), TTL, Random, NoEviction
    sweepInterval: 60.0,      // minimum seconds between opportunistic maintenance passes
    strict: false,            // true: throw on internal read errors instead of a miss
);

Eviction policies: lru (default, evicts least-recently-used), ttl (soonest-to-expire first), random, and noeviction (rejects the write instead of evicting). LFU is a documented TODO in every implementation. Unfamiliar with an abbreviation like LRU? See the glossary.

Laravel

None of this is required to use lytecache from Laravel — set CACHE_STORE=lytecache and go. The options above are still there if you need them, in config/cache.php's stores.lytecache (publish the stub with php artisan vendor:publish --tag=lytecache-config), and path defaults to storage_path('framework/cache/lytecache.db') instead of the platform cache directory.

API reference

Method reference

Grouped by what you're trying to do. Every group below shows all five languages side by side — flip the toggle once and it stays flipped for the rest of the page.

Set & get

cache.set("key", "value")
cache.set("key", "value", ttl=300)
cache.get("key")                 # None if missing or expired
cache.get("key", "default")      # custom default on miss
cache.delete("key1", "key2")     # returns count actually deleted
cache.exists("key")
cache.set("key", "value");
cache.set("key", "value", Duration.ofMinutes(5));
String val = cache.getString("key");   // null if missing or expired
Long n = cache.getLong("key");
Double d = cache.getDouble("key");
byte[] b = cache.getBytes("key");
cache.delete("key1", "key2");          // returns count actually deleted
cache.exists("key");
cache.set("key", "value");
cache.set("key", "value", { ttl: 300 });
cache.get("key");                // undefined if missing or expired
cache.get("key", "default");     // custom default on miss
cache.delete("key1", "key2");    // returns count actually deleted
cache.exists("key");
cache.Set("key", "value")
cache.Set("key", "value", lytecache.TTL(5*time.Minute))
var v string
found, _ := cache.Get("key", &v)      // found=false if missing or expired
s, found, _ := cache.GetString("key")  // typed convenience wrapper
cache.Delete("key1", "key2")           // returns count actually deleted
cache.Exists("key")
$cache->set("key", "value");
$cache->set("key", "value", ttl: 300);
$cache->get("key");                 // null if missing or expired
$cache->get("key", "default");      // custom default on miss
$cache->delete("key1", "key2");     // returns count actually deleted
$cache->has("key");

Objects & generics

Anything JSON can represent — dicts, lists, dataclasses, POJOs (a plain object with no special base class or annotations — see glossary), records — round-trips as portable JSON, readable from any of the languages.

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int

cache.set("p:1", Person("Samson", 30))
cache.get("p:1")                # plain dict by default
cache.get("p:1", cls=Person)    # typed: Person(name="Samson", age=30)
record Person(String name, int age) {}

cache.set("p:1", new Person("Samson", 30));
cache.get("p:1", Person.class);

// Generic types a raw Class can't express:
Map<String, Long> scores = cache.get("scores",
    new TypeReference<Map<String, Long>>() {});

class Person {
  constructor(public name: string, public age: number) {}
}

cache.set("p:1", new Person("Samson", 30));
cache.get("p:1");                             // plain object by default
cache.get("p:1", undefined, { into: Person }); // typed: instanceof Person
type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

cache.Set("p:1", Person{Name: "Samson", Age: 30})
var p Person
found, _ := cache.Get("p:1", &p) // typed: struct fields via encoding/json tags
final class Person {
    public function __construct(
        public string $name,
        public int $age,
    ) {}
}

$cache->set("p:1", new Person("Samson", 30));
$cache->get("p:1");                        // plain array by default
$cache->get("p:1", class: Person::class);  // typed: Person(name: "Samson", age: 30)

Add / replace / atomic swap

"NX" and "XX" below are the classic Redis flag names for these two semantics; see the glossary.

cache.add("key", "value", ttl=60)      # set only if absent (SET NX)
cache.replace("key", "value", ttl=60)  # set only if present (SET XX)
cache.get_set("key", "new")            # atomic swap, returns the old value
cache.add("key", "value", ttl);      // set only if absent (SET NX)
cache.replace("key", "value", ttl);  // set only if present (SET XX)
cache.getSet("key", "new");          // atomic swap, returns the old value
cache.add("key", "value", { ttl: 60 });      // set only if absent (SET NX)
cache.replace("key", "value", { ttl: 60 });  // set only if present (SET XX)
cache.getSet("key", "new");                  // atomic swap, returns the old value
cache.Add("key", "value", lytecache.TTL(60*time.Second))      // set only if absent (SET NX)
cache.Replace("key", "value", lytecache.TTL(60*time.Second))  // set only if present (SET XX)
var old string
cache.GetSet("key", "new", &old) // atomic swap, decodes the old value into old
$cache->add("key", "value", ttl: 60);      // set only if absent (SET NX)
$cache->replace("key", "value", ttl: 60);  // set only if present (SET XX)
$cache->getSet("key", "new");              // atomic swap, returns the old value

Batch operations

cache.set_many({"a": "1", "b": "2"}, ttl=60)   # single transaction
cache.get_many(["a", "b", "missing"])           # {"a": "1", "b": "2"}
cache.setAll(Map.of("a", "1", "b", "2"), ttl);   // single transaction
cache.getAll(List.of("a", "b", "missing"));       // {a=1, b=2}
cache.setMany({ a: "1", b: "2" }, { ttl: 60 });   // single transaction
cache.getMany(["a", "b", "missing"]);              // Map(2) {'a' => '1', 'b' => '2'}
cache.SetMany(map[string]any{"a": "1", "b": "2"}, lytecache.TTL(60*time.Second))
results, _ := cache.GetMany([]string{"a", "b", "missing"}) // map[string]RawValue, decode per key
$cache->setMany(["a" => "1", "b" => "2"], ttl: 60);   // single transaction
$cache->getMany(["a", "b", "missing"]);                // ["a" => "1", "b" => "2"]

Atomic counters

A single SQL UPSERT (an insert-or-update statement -- see glossary) per call — correct under concurrent access from many threads or processes, never a read-modify-write race.

cache.incr("hits")             # +1
cache.incr("hits", 5)          # +5
cache.decr("hits")             # -1
cache.incr_float("ratio", 0.5)
cache.incr("hits");              // +1
cache.incr("hits", 5);           // +5
cache.decr("hits");              // -1
cache.incrDouble("ratio", 0.5);
cache.incr("hits");              // +1
cache.incr("hits", 5);           // +5
cache.decr("hits");              // -1
cache.incrFloat("ratio", 0.5);
cache.Incr("hits", 1)          // +1
cache.Incr("hits", 5)          // +5
cache.Decr("hits", 1)          // -1
cache.IncrFloat("ratio", 0.5)
$cache->incr("hits");             // +1
$cache->incr("hits", 5);          // +5
$cache->decr("hits");             // -1
$cache->incrFloat("ratio", 0.5);

Key scanning

for key in cache.keys("session:*"):   # lazy iterator, glob syntax
print(key)
cache.keys("session:*")                // lazy Stream<String>, GLOB syntax
.forEach(System.out::println);
for (const key of cache.keys("session:*")) {  // lazy generator, GLOB syntax
  console.log(key);
}
for key, err := range cache.Keys("session:*") { // iter.Seq2, GLOB syntax
    fmt.Println(key, err)
}
foreach ($cache->keys("session:*") as $key) {  // lazy Generator, GLOB syntax
    echo $key;
}

Stats & maintenance

cache.stats()
# {'hits': 42, 'misses': 3, 'hit_rate': 0.93, 'key_count': 12,
#  'size_bytes': 8291, 'evictions': 0, 'path': '...'}
cache.vacuum()   # reclaim disk space
cache.close()    # shut down cleanly
CacheStats stats = cache.stats();
stats.hitRate();     // percentage
stats.keyCount();
stats.sizeBytes();
stats.path();
cache.vacuum();      // reclaim disk space
cache.close();       // shut down cleanly (or use try-with-resources)
cache.stats();
// { hits: 42, misses: 3, hitRate: 0.93, keyCount: 12,
//   sizeBytes: 8291, evictions: 0, path: '...' }
cache.vacuum();   // reclaim disk space
cache.close();    // shut down cleanly (or use Symbol.dispose)
stats, _ := cache.Stats()
// {Hits:42 Misses:3 HitRate:0.93 KeyCount:12 SizeBytes:8291 Evictions:0 Path:"..."}
cache.Vacuum()  // reclaim disk space
cache.Close()   // shut down cleanly (idempotent)
$cache->stats();
// CacheStats(hits: 42, misses: 3, hitRate: 0.93, keyCount: 12,
//   sizeBytes: 8291, evictions: 0, path: "...")
$cache->vacuum();   // reclaim disk space
$cache->close();    // shut down cleanly (idempotent; __destruct() also calls it)

Distributed locks

Process-safe: built on the same atomic add() semantics as everything else, so only one holder — across threads and processes — can hold a given lock name at once.

with cache.lock("resource", timeout=30):
do_critical_work()
try (CacheLock lock = cache.lock("resource", Duration.ofSeconds(30))) {
    doCriticalWork();
}
using lock = cache.lock("resource", { timeoutMs: 30_000 });
doCriticalWork();
lock, err := cache.Lock("resource", 30*time.Second)
defer lock.Release()
doCriticalWork()
$cache->lock("resource", timeout: 30.0)->block(function () {
    doCriticalWork();
});

Read-through memoization

@cache.memoize(ttl=3600)
def expensive(x):
    return compute(x)
String result = cache.memoize("expensive:" + x, Duration.ofHours(1),
() -> compute(x));
const result = cache.memoize(`expensive:${x}`, 3600, () => compute(x));
result, err := lytecache.Memoize(cache, fmt.Sprintf("expensive:%v", x), time.Hour,
    func() (T, error) { return compute(x) })
$result = $cache->remember("expensive:{$x}", 3600, fn () => compute($x));

Cross-language

Storage spec & type codes

Every implementation reads and writes the identical schema — one SQLite file, WAL mode, PRAGMA busy_timeout=5000 so cross-process contention waits instead of failing. Every value is tagged with a value_type code:

Code Type Encoding
0 Bytes Raw bytes, stored as-is
1 String UTF-8 text
2 Integer UTF-8 decimal text — not binary. This is what lets incr/decr be one atomic SQL UPSERT in every language.
3 Float UTF-8 decimal text, same reasoning as integers
4 JSON Any object, dict, list, dataclass, POJO, record, JS object, Go struct/map/slice, or PHP array/object/enum
5 Python pickle Python-only escape hatch; reading it from Java, Node.js, Go, or PHP raises a clear error, never garbage
6 Java serialized Reserved, never written; reading it from Python, Node.js, Go, or PHP raises a clear error

Node.js has a single number type rather than Python's int/float or Java's long/double, so its implementation picks type code 2 vs. 3 by shape (Number.isInteger()) rather than by caller intent, using bigint for integers beyond Number.MAX_SAFE_INTEGER. See lytecache-node/SPEC.md for the full rules.

Go, like Python and Java, has distinct integer and floating-point types, so it decides type codes by the value's actual Go type rather than by shape: any Go integer type stores as code 2, float32/float64 as code 3. A uint64 beyond what signed 64-bit integers can hold is rejected outright rather than silently truncated. See lytecache-go/SPEC.md for the full rules.

PHP strings are ambiguous between "raw bytes" and "text," unlike the other languages' distinct byte-array types, so lytecache uses a Bytes wrapper class to mean code 0 explicitly — a plain PHP string always stores as code 1. Objects serialize via JsonSerializable when implemented, else their public properties; a DateTimeInterface serializes to an RFC 3339 string and a BackedEnum to its backing value, matching the conventions above. See lytecache-php/SPEC.md for the full rules.

The zero-config default path derivation (<project-id> = SHA-256 of the resolved working directory) is byte-for-byte identical across every implementation, so all of them land on the same file for the same project directory — see Where is my data? above.

Cross-language

Glossary

This page uses a handful of abbreviations without expanding them inline. Here's what each one means.

Term Full name What it means here
API Application Programming Interface The set of methods a library exposes for other code to call — in this case, set/get/incr/etc.
CLI Command-Line Interface A program you run and control from a terminal, as opposed to a GUI.
TTL Time To Live How long a cached value is kept before it's treated as expired.
LRU Least Recently Used Eviction policy: when the cache is full, remove the key that hasn't been read or written in the longest time.
LFU Least Frequently Used Eviction policy that removes the key with the fewest accesses. Documented as a future addition, not yet implemented.
SQL Structured Query Language The language used to talk to SQLite — every read and write here compiles down to one SQL statement.
WAL Write-Ahead Logging A SQLite journaling mode that lets readers and a writer access the database file concurrently without blocking each other.
JSON JavaScript Object Notation The plain-text format used to store objects, arrays, and other structured values portably across every language.
POJO Plain Old Java Object An ordinary Java object with no special base class, interface, or framework annotation required.
GLOB Global (pattern matching) Shell-style wildcard syntax (*, ?, [...]) used by keys(pattern) to match key names.
NX / XX Not eXists / already eXists Redis-style flag names: add() behaves like SET NX (only if absent), replace() like SET XX (only if present).
UPSERT Update + Insert A single SQL statement that inserts a new row or updates the existing one, used so counters and locks stay atomic under concurrency.
CJS / ESM CommonJS / ECMAScript Modules Node.js's two module systems (require() vs. import). The Node.js package ships both.
SHA-256 Secure Hash Algorithm, 256-bit A one-way fingerprint function used to derive each project's default cache filename from its working directory.

Tooling

Command-line interface

Inspect and manipulate any lytecache database file from a shell, the way redis-cli does for a server. It's built entirely on lytecache-go's public API, so it can read and write a file created by any of the five languages above.

Install

brew install lytecache/tap/lytecache
# or, without Homebrew:
curl -fsSL https://raw.githubusercontent.com/lytecache/lytecache-cli/main/install.sh | sh
scoop bucket add lytecache https://github.com/lytecache/scoop-bucket
scoop install lytecache
go install github.com/lytecache/lytecache-cli/cmd/lytecache@latest

Prebuilt binaries, SHA-256 checksums, and .deb/.rpm packages are attached to every GitHub release; a winget manifest ships the same way, pending submission to microsoft/winget-pkgs.

Docker

Also published as a multi-arch (linux/amd64/linux/arm64) container image -- a ~7 MB scratch-based image containing nothing but the static binary, for inspecting a cache file without installing Go. This packages only the CLI; lytecache has no server mode, so there's no listener, no daemon, no healthcheck to configure.

Mount the same volume your application uses at /var/cache/lytecache and set LYTECACHE_PATH to the value the app already uses, and the CLI needs no --db flag at all -- it resolves that env var exactly the way every library on this page does:

Terminal
docker run --rm -v myapp-cache:/var/cache/lytecache \
  -e LYTECACHE_PATH=/var/cache/lytecache/cache.db \
  ghcr.io/lytecache/lytecache:latest stats

With Compose, add a service like this alongside whatever already runs your app — matching its LYTECACHE_PATH and mounting the same named volume is what makes --db unnecessary:

docker-compose.yml
services:
  # ... your existing app service ...

  lytecache-cli:
    image: ghcr.io/lytecache/lytecache:latest
    profiles: ["tools"]     # keeps it out of `docker compose up`
    environment:
      LYTECACHE_PATH: /var/cache/lytecache/cache.db   # same value your app sets
    volumes:
      - lytecache-data:/var/cache/lytecache            # same volume your app mounts

volumes:
  lytecache-data:

docker compose run --rm lytecache-cli stats (or keys, or no command at all for the REPL) then just works — run reaches a profiled service directly even though up skips it. The volume goes on the directory, never the .db file itself, since WAL mode creates cache.db-wal/cache.db-shm beside it. Embed the binary in your own image with one line instead, since it's fully static and doesn't care what base image it lands in:

Dockerfile
COPY --from=ghcr.io/lytecache/lytecache:latest /lytecache /usr/local/bin/lytecache

Full details -- the --db one-liner for an arbitrary host file, the interactive REPL, a shell alias, the Compose profile pattern, and why sharing one .db file over NFS or Kubernetes ReadWriteMany isn't supported (SQLite's locking depends on guarantees network filesystems don't reliably provide) -- are in lytecache-cli's README.

A session

samson@lytecache: ~

One-shot commands (script-friendly, shown above) and the interactive REPL share the exact same command tree. The REPL just opens the database once for the whole session instead of reopening it per command. Command names are case-insensitive there, and Ctrl-C cancels the current line without leaving.

Commands

CommandDescription
get <key>Print a value, JSON pretty-printed by default, or with --raw for exact bytes
set <key> [value]Set a value; type is inferred unless --type forces it
del <key>...Delete one or more keys; prints how many actually existed
exists <key>Prints 1/0; exit code matches
ttl / expire / persist / touchInspect and manage a key's TTL
incr / decrAtomically adjust a counter; prints the new value
keys [pattern] (alias scan)List matching keys; --long adds type/ttl/size columns
stats (alias info)Key count, size, hit/miss counters, evictions, path
flushDelete every key in the current namespace (prompts unless --yes)
maintain / vacuumRun a maintenance pass / reclaim disk space
whichPrint the resolved database path and whether it exists
dump <key>Raw row metadata, the view you reach for when debugging
watch [interval]Redraw stats every interval seconds until Ctrl-C

Exit codes

Scripts can rely on these without parsing output:

CodeMeaning
0Success
1A read found nothing ((nil)), or a boolean result was false
2Usage error (bad arguments/flags)
3Database error

Cross-language example

Since every implementation shares one on-disk format (see Storage spec above), the CLI can inspect a cache file written by any of them. A Python process wrote this:

Python
cache.set("config", {"theme": "dark", "timeout": 30})
Terminal
$ lytecache --db ~/.cache/lytecache/abc123.db get config
{
  "theme": "dark",
  "timeout": 30
}

No Python installation, no shared server, no export step: just the same SQLite file on disk. The two type codes the CLI can't decode — 5 for Python pickle, 6 for Java serialization — render as (non-portable value: python-pickle, N bytes) instead of erroring out or dumping garbage.

Prefer a browser?

Everything above also works as a web console — see Admin UI below.

Tooling

Admin UI

A local web console for the same database files the CLI reads — think RedisInsight or pgAdmin, not a cache server. It has no wire protocol and no application ever connects to it; it's lytecache-go's public API with a browser on top, launched with lytecache ui --port 7070.

It ships in the exact same binary as the CLI above — there's no separate install. If you haven't installed lytecache yet, see Install in the CLI section, or jump straight to Docker below if you'd rather not install anything locally at all.

Fleet dashboard

Every configured database side by side — key count, size, hit rate, evictions, and a health flag when usage or expired-present counts creep up.

Delete-only, on purpose

It can remove data — never create or alter it. There's no route that accepts a new or edited value; the handler doesn't exist to reach.

Prometheus /metrics

Per-database gauges for key count, size, evictions, and expired-present, scraped from zero-write library calls.

Runs in the background

lytecache service install registers it with launchd, systemd, or Windows' SCM — survives logout, restarts on boot.

Install and run it, step by step

  1. Install lytecache

    It's one binary — the CLI and the Admin UI ship together, so there's nothing separate to install for either.

    Terminal
    brew install lytecache/tap/lytecache

    On Linux/Windows/from source, or want the Docker image instead? See Install in the CLI section above.

  2. Point it at a database — or don't

    Know the path? Name it with --db. Not sure, or just testing? Skip this step entirely — running with zero flags auto-scans the standard cache locations for you (see the callout further down).

  3. Run it

    In the foreground first, to see it work — Ctrl+C stops it, nothing persists:

    Terminal
    lytecache ui

    Happy with it? Keep it running in the background instead (survives logout and reboot) — see Running as a background service below.

  4. Open it and log in

    Visit http://127.0.0.1:7070 and log in with admin/admin — that lands you on the fleet dashboard.

  5. Change the password before exposing it anywhere

    Only matters if you plan to reach this from beyond 127.0.0.1 — and even then it's enforced, not just advised: binding elsewhere while the password is still admin refuses to start outright. Run lytecache ui passwd whenever you're ready for that.

Quickstart

samson@lytecache: ~

Open http://127.0.0.1:7070, log in with admin/admin, and you're at the fleet dashboard. Passing --allow-delete turns on delete/flush/maintain; without it, those routes return 403 whether or not the browser shows a button for them.

No --db handy? Run lytecache ui with no flags at all — with zero configuration given, it falls back to scanning two standard locations: the same <platform cache dir>/lytecache directory the library itself defaults to when your app doesn't call WithPath either, and /var/cache/lytecache — this project's own documented Docker volume-mount convention (see Docker below), so the same zero-flag default works whether you're running this on bare metal or in a container. Passing any --db/--scan/config-file entry turns this off entirely — it only applies when nothing else was configured.

Using it

Click any database row to drill into its key browser. The sidebar lists every namespace with its key count, so a mismatched namespace — the single most common "my key isn't there" surprise — is hard to miss. Click a key to open the value viewer: JSON pretty-printed and collapsible, everything else masked behind a "Reveal value" click until you ask for it. With --allow-delete, a delete button appears there too, plus bulk delete and a flush-namespace control that requires typing the namespace name back — no accidental wipes from a stray click.

/ focuses the search box from anywhere (it searches every configured database, not just the one you're in); arrow keys move through a key list, Esc clears the current selection or search.

Guardrails, not a settings screen

Binding to anything other than 127.0.0.1 while the password is still admin refuses to start outright, and reaching it non-locally with a reset default password forces a password change before anything else is reachable — neither is a flag you can turn off. Reaching a remote instance? Prefer an SSH tunnel (ssh -L 7070:localhost:7070 you@host) over exposing the port at all.

Docker

The image ships the ui subcommand too — bind 0.0.0.0 inside the container (a loopback bind is unreachable through published ports regardless of Docker's NAT rules) and re-restrict back to 127.0.0.1 on the host side:

Terminal
docker run --rm -p 127.0.0.1:7070:7070 \
  -v myapp-cache:/var/cache/lytecache \
  -v lytecache-ui-config:/home/lytecache/.config/lytecache \
  -e LYTECACHE_UI_PASSWORD=change-me \
  ghcr.io/lytecache/lytecache:latest \
  ui --host 0.0.0.0 --insecure --no-metrics --db orders=/var/cache/lytecache/orders.db

Mount the config volume, not just the cache data volume — ui.yaml holds the credentials and session secret, and without it persisted, the admin password resets to admin/admin on every container recreation.

With Compose, add it as a profiled service alongside whatever already runs your app — matching its LYTECACHE_PATH and mounting the same named volume is what makes --db unnecessary, same as the CLI service above:

docker-compose.yml
services:
  # ... your existing app service ...

  lytecache-ui:
    image: ghcr.io/lytecache/lytecache:latest
    pull_policy: always    # always check for a newer :latest before (re)creating
    profiles: ["tools"]     # keeps it out of `docker compose up`
    command: ["ui", "--host", "0.0.0.0", "--port", "7070", "--allow-delete", "--insecure", "--no-metrics"]
    environment:
      LYTECACHE_PATH: /var/cache/lytecache/cache.db   # same value your app sets
      LYTECACHE_UI_PASSWORD: ${LYTECACHE_UI_PASSWORD:?set a real admin password first}
    volumes:
      - lytecache-data:/var/cache/lytecache            # same volume your app mounts
      - lytecache-ui-config:/home/lytecache/.config/lytecache
    ports:
      - "7070:7070"

volumes:
  lytecache-data:
  lytecache-ui-config:

docker compose --profile tools up -d lytecache-ui, then open http://127.0.0.1:7070. Note this ports: mapping publishes on every network interface the host has, not just loopback — fine on a machine you trust the network of, but anyone who can reach this host's IP can now reach the UI too. If that's not what you want, restrict it back to the host itself with "127.0.0.1:7070:7070" instead. Either way, --insecure is only a reasonable trade-off as long as nothing untrusted shares that reachable network — swap it for --tls-cert/--tls-key once this is reachable beyond a single machine. The full example — plus a --scan-based fleet view across several services' caches sharing one volume — is in lytecache-cli's examples/docker-compose.yml.

Pulling a newer image

Docker doesn't re-fetch an already-present :latest image just because a newer one exists in the registry — docker compose up alone will happily keep running whatever you already have cached locally. Two ways to actually get the newer one:

Terminal
# pull, then recreate the container so it actually uses what was just pulled
docker compose pull lytecache-ui
docker compose up -d --force-recreate lytecache-ui

# or do both in one command
docker compose up -d --pull always lytecache-ui

To make this the default without remembering a flag every time, add pull_policy: always to the service (already in the snippet above) — then a plain docker compose up -d lytecache-ui always checks for a newer :latest first.

Multiple app services, one Admin UI

The single most common setup mistake: if each of your app services caches to its own file or volume, lytecache-ui has to actually mount the same volume(s) they write to. Pointing it at a leftover or unrelated volume "works" in the sense that the container starts fine — it just shows an empty dashboard, because it's genuinely looking at a different place than your app does.

Two patterns handle this correctly, trading a bit of setup for a bit of isolation:

One shared volume, one file per service

  • Each service gets its own LYTECACHE_PATH filename (admin.db, pos.db, ...) but mounts the same volume
  • lytecache-ui mounts that one volume and uses --scan — new services need zero lytecache-ui changes
  • Recommended default for most fleets

One shared file for everything

  • Every service's writes serialize against every other service's writes, not just its own — SQLite's single-writer model has no concept of "unrelated" callers
  • One corrupted/locked file takes down cache access for your whole fleet at once, not just the service that caused it
  • Collapses the dashboard to one row instead of one per service
  • Not recommended unless you've implemented per-service namespace isolation in application code (see below) and accept the write-contention trade-off

A third option — one separate volume per service, exactly as isolated as separate files, just more explicit — works too: mount all of them into lytecache-ui, each at its own subdirectory, and use repeatable --db name=path flags instead of --scan. More manual wiring per new service, but a lock or corruption issue in one file can't touch any other.

SymptomCauseFix
Dashboard shows "No databases configured", or a database with zero keys lytecache-ui mounts a different volume than your app services actually write to Mount the exact same volume(s) your services use — never a separate one "for the UI"
A LYTECACHE_NAMESPACE (or similarly-named) env var seems to do nothing It isn't a real setting — no implementation reads it from the environment Isolate services by file (distinct LYTECACHE_PATH per service), or by namespace via WithNamespace(...) in actual application code
docker compose run lytecache-cli stats always shows the same one service's data, or a file that doesn't exist A shared LYTECACHE_PATH env var (often via a YAML anchor reused across services) defaults it to one fixed file Drop the default and pass --db /path/to/service.db explicitly on each invocation instead
service "x" refers to undefined volume "y": invalid compose project A service's volumes: references a name that was never declared at the top level Every volume name used anywhere in the file must also appear under the top-level volumes: key
Every app service looks migrated to the new volume layout, but the dashboard is still empty lytecache-ui itself didn't get updated — easy to update every app service and forget the one service that actually has to see all of them Double-check lytecache-ui's own volumes:/command: match whatever your app services just moved to

Running as a background service

Installed via Homebrew?

service install already ran for you, automatically, the moment brew install/brew upgrade finished (registered only — never started, so nothing is listening until you choose to). Skip straight to lytecache service start below.

Terminal
lytecache service install --db orders=/data/orders.db --allow-delete
lytecache service start
lytecache service status

Every flag lytecache ui accepts is also accepted by service install, and gets persisted so it starts identically every time — a user LaunchAgent on macOS, a user systemd unit on Linux (--system for a system-wide one), and the Service Control Manager on Windows when elevated, falling back to a Scheduled Task at logon otherwise. Re-running service install any time (with different flags, e.g. to add --db) replaces the persisted config — no need to uninstall first.

Full details — masking sensitive values, the audit log, a paste-ready Prometheus scrape_config, and the complete default-credential guardrail matrix — are in lytecache-cli's docs/ui.md.