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.
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
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.dbin 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
setIntervaltimer in Node.js, a goroutine in Go) that runs everysweep_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 bysweepInterval) — Laravel apps should also schedule thelytecache:maintainartisan 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()andCache::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:
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:
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:
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
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
| Command | Description |
|---|---|
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 / touch | Inspect and manage a key's TTL |
incr / decr | Atomically 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 |
flush | Delete every key in the current namespace (prompts unless --yes) |
maintain / vacuum | Run a maintenance pass / reclaim disk space |
which | Print 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:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | A read found nothing ((nil)), or a boolean result was false |
| 2 | Usage error (bad arguments/flags) |
| 3 | Database 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:
cache.set("config", {"theme": "dark", "timeout": 30})
$ 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
-
Install
lytecacheIt's one binary — the CLI and the Admin UI ship together, so there's nothing separate to install for either.
Terminalbrew install lytecache/tap/lytecacheOn Linux/Windows/from source, or want the Docker image instead? See Install in the CLI section above.
-
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). -
Run it
In the foreground first, to see it work —
Ctrl+Cstops it, nothing persists:Terminallytecache uiHappy with it? Keep it running in the background instead (survives logout and reboot) — see Running as a background service below.
-
Open it and log in
Visit
http://127.0.0.1:7070and log in withadmin/admin— that lands you on the fleet dashboard. -
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 stilladminrefuses to start outright. Runlytecache ui passwdwhenever you're ready for that.
Quickstart
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:
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:
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:
# 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_PATHfilename (admin.db,pos.db, ...) but mounts the same volume lytecache-uimounts that one volume and uses--scan— new services need zerolytecache-uichanges- 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.
| Symptom | Cause | Fix |
|---|---|---|
| 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.
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.