This release is a pre-release and may not be stable for production use.
TinyStore for Python
kv, jobs, blobs, SQL, records and metrics in one directory, served by a sidecar the SDK starts. A Go program embeds the same engines; the guides show the three languages side by side.
pip install tinyshed-tinystore
Then import tinystore. This platform's wheel carries the tinystore binary;
TINYSTORE_BIN, open's binary or PATH name another. Python 3.12 or
later, asyncio.
The wheel installs the binary as the tinystore command too:
tinystore logs ./data -f, or without installing anything
uvx --from tinyshed-tinystore tinystore status ./data, and for an AI agent
claude mcp add tinystore -- uvx --from tinyshed-tinystore tinystore mcp ./data.
Open
import tinystore
async with tinystore.open("./data") as store: # the directory's sidecar, started when none runs
...
async with tinystore.open(directory, private=True) as store: # a child of this process, for tests and scripts
...
async with tinystore.connect("tls://db.internal:7070", token=token) as store:
...
async with tinystore.open(directory, private=True, clock=datetime(2026, 10, 3, 9, tzinfo=UTC)) as store:
await store.clock.advance("1h") # keys expire and jobs come due without a wait
await store.backup("backup.zip") # every engine in one checked zip; tinystore restore takes it back
open returns once the server has answered, so a directory that cannot be
served fails there. Closing hands over the handlers' records and the
instruments' last values; a sidecar leaves once its last connection has been
idle 30 s.
kv
sessions = store.kv.bucket("sessions", Session, sliding="30d")
await sessions.of(user.id).set(token, session)
found = await sessions.of(user.id).get(token) # None when absent or expired
created, entry = await sessions.of(user.id).set_entry_if_absent(token, session) # this one, or the one there
views = store.kv.counters("views")
await views.add("/home")
page = await sessions.scan(limit=100) # Page(items, next); next goes back as after
async for entry in sessions.all():
...
async def sign_in(tx: tinystore.Tx) -> int: # reads, decides, writes; runs again if a key it read changed
user_id = await codes.with_tx(tx).take(digest(code))
if user_id is None:
raise InvalidCodeError
sessions.of(user_id).with_tx(tx).set(digest(token), session)
return user_id
user_id = await store.kv.tx(sign_in)
A bucket's type is given once, where it opens: str, bytes, int,
float, bool, or a dataclass, a TypedDict or anything else JSON holds.
set_if_absent and set_entry_if_absent write only where no live key is, in
one write: two processes making the same key, a day's salt or a claim, keep
one, where a get and then a set keep both.
cfg = await store.kv.config("app", Settings, file=tomllib.load(f), env_file=".env")
cfg.value.port # PORT=3000 in .env makes it 3000
await cfg.update({"port": 4000}) # kept: 4000 after a restart too, at once in every process
await cfg.reset("port") # back to .env's 3000
cfg.watch(lambda c: server.set_port(c.port))
limit = store.kv.limiter("api", rate="100/s", burst=20)
ok, left, retry_after = await limit.of(tenant).allow(user_id)
ai = store.kv.quota("ai", session="100/5h", weekly="300/7d")
usage = await ai.allow(user.id) # one of each window, or none: usage.windows["weekly"].left
A config is a dataclass or a model whose defaults are its own: a file's
values, then the environment, then what update kept go over them, a variable
named by its field, db_url as DB_URL, after prefix when given. A number,
true, a list a.com,b.com or JSON read as the default's kind, and one that
does not is InvalidError at open, naming it. env={"db_url": "DATABASE_URL"} names a variable itself; secret fields are never kept;
validate checks each change. A quota's windows count a use together or not
at all, each from a key's first use; get reads them without using any,
refund gives uses back.
charges = store.kv.once("charges", Receipt) # answers kept a day
receipt = await charges.run(request_id, lambda: pay.charge(order, request_id))
run returns the answer kept under a key, or runs the function and keeps its
answer: a call of the key meanwhile, from any client, waits for it and gets
the same answer, and an exception keeps nothing, so the next call runs again.
The function runs once a key while its call lives; an effect outside the
store, a charge, carries the key too.
jobs
reminders = store.jobs.queue("reminders", Reminder)
await reminders.enqueue(Reminder(note=1), after="1h", key="note/1")
async def remind(job: tinystore.Job[Reminder]) -> None:
await send(job.value.note)
await reminders.work(remind, workers=4) # until the task is cancelled
A handler's return acknowledges its job and an exception retries it, waiting
longer each time; job.retry, job.fail and job.snooze say otherwise.
Cancelled, work gives back the jobs its handlers did not finish, uncounted.
videos = store.jobs.queue("videos", Video, max_running=2)
await videos.enqueue(video, key=video.id)
async for s in videos.watch(video.id): # waiting 3 … running 0.4 … done
await send(s.state, s.ahead, s.progress)
async def transcode(job: tinystore.Job[Video]) -> None:
await encode(job.value, on_progress=job.progress)
await videos.work(transcode)
get says where a job is: waiting, with how many jobs run ahead of it,
running, with the progress its handler last reported, failed, or done
while keep_done keeps its key; watch yields it again at each change until
it ends, cancelled included, and ran and took say when the last run a
handler finished began and how many seconds it took: a schedule's last run
beside its next, at. job.progress takes any JSON within 4 KiB and
sends the latest at most ten times a second. cancel takes a running job too:
its handler's task is cancelled, and what it leaves settles nothing.
max_running bounds the jobs running at once across every worker of the
store.
A step of a job's run keeps its answer, so that the attempt after a failure
does not run it again: hits = await job.step("search", lambda: search(q)).
blobs
files = store.blobs.bucket("files")
await files.put("avatars/42.png", Path("avatar.png").read_bytes(), content_type="image/png")
avatar = await files.get("avatars/42.png") # None when absent
data = await avatar.read() # a whole read checks every byte
SQL
app = await store.sql("app", migrations="./migrations")
await app.exec("insert into notes (body) values (?)", body)
notes = await app.all(Note, "select id, body from notes where author = ?", author)
async with app.batch() as tx: # one transaction, all or none
tx.exec("update notes set body = ? where id = ?", body, note_id)
tx.exec("insert into edits (note) values (?)", note_id)
index = store.jobs.queue("index", int, in_=app) # the queue lives in sql/app.db
async with app.batch() as tx:
tx.exec("update notes set body = ? where id = ?", body, note_id)
index.with_tx(tx).enqueue(note_id) # commits with the update, or not at all
On Python 3.14 a statement may be a template, t"… where author = {author}",
its values arguments and never SQL. A value comes back as SQLite keeps it.
records
logging.getLogger().addHandler(store.records.handler("api", redact=["password"])) # never waits for the server
with tinystore.trace(trace_id, span_id): # what is logged inside carries the trace
logging.info("charged")
with tinystore.context(request_id=request_id): # every line inside carries request_id, tasks it starts too
logging.warning("slow request", extra={"ms": 1200})
page = await store.records.scan(since="1h", min_level="warn", limit=100)
resets = await store.records.scan(since="1h", search="connection reset") # case ignored
more = await store.records.scan(since="1h", min_level="warn", limit=100, after=page.next)
async for record in store.records.all(since="24h", trace_id=trace):
ms = tinystore.fields(record.attrs)["ms"] # a record's (key, json) pairs, as json.loads reads them
worker = store.records.lines("worker") # another program's output, cut anywhere
async for chunk in process.stdout:
worker.write(chunk)
A handler holds 1024 records and hands them over every second or once half
of them wait; what does not fit is dropped, counted in handler.dropped and
said on stderr. A handler is for lines a burst may cost: what must all be kept
goes through records.append, which returns once it is, and a larger buffer
holds a longer burst.
Each line also goes to stderr as it is logged: pretty on a terminal, one JSON
object a line otherwise, the bytes Go's and Bun's loggers write.
console="pretty" | "json" | "off" and stdout=True choose, and a handler
of events, a view a request, takes "off" or fills the program's log;
redact hides the values of fields of those names, at any depth, the case
ignored, in the store and on the console. A program that wants the logger and
not the records takes a handler without a store; its children are
logging's own:
logging.basicConfig(handlers=[tinystore.handler("app", redact=["password"])], level=logging.INFO)
log = logging.getLogger("app.db") # the console alone, nothing kept
metrics
store.metrics.counter("http_requests_total").labels(route="/users").inc()
store.metrics.gauge("queue_depth").set(12)
latency = store.metrics.timer("http_request_ms")
with latency.labels(route="/users").measure(): # await inside is timed too
user = await users.get(user_id)
await store.metrics.ingest({"name": "cpu", "kind": "gauge", "labels": {"host": "web-1"}, "samples": [(now, 0.42)]})
series = await store.metrics.read(name="cpu", match={"host": "web-1"}, since="1h") # [Series(..., times, values)]
failing = await store.metrics.read(
name="http_requests_total", since="1h", where={"status": tinystore.one_of("500", "502")}
)
buckets = await store.metrics.aggregate(name="http_requests_total", since="24h", width="1h", op="increase")
routes = await store.metrics.aggregate(name="http_requests_total", since="24h", width="1h", op="rate", by=["route"])
A read answers each series as columns, times in unix milliseconds and
values an array("d"), as ingest takes them too beside (time, value)
pairs. A sample comes back bit for bit, -0.0 and a NaN's payload included; a
range is since, or from_ and to. metrics.explain(...) says what a read
or an aggregate would spend of its limits before it runs.
A timer's measure() times its block whether it returns or raises, in a with
or an async with; record(d) adds a duration of its own, seconds, a
timedelta or "250ms". Every flush writes http_request_ms_count and
http_request_ms_sum, counters, and the longest since the flush before,
http_request_ms_max, so that a range's mean is the increase of its sum over
the increase of its count. A series the store refuses, a label it cannot keep,
is left out from then on and said once, and the other instruments go on being
written.
Durations, errors and cancellation
A duration is a timedelta, seconds, or text such as "1h30m". Every error
is its code's class, a TinystoreError: InvalidError, ConflictError,
LimitError, TooOldError and the rest, each carrying what it names; a
LimitError names the bound, what the call wanted and the bound. A cancelled
task cancels its call, one CANCEL on the wire. await store.status() says
what the server is: its version, its protocol, its engines.
What fails where no call waits is said on stderr, as a handler of stream
tinystore writes a line: an instrument's flush or a handler's write that
failed, and its recovery; a gauge's function that raised; a refused
instrument; the lines a full handler dropped. A failure repeated is said again
ten minutes on, or when it changes, as Go's store logs its own.
Metadata
Release files for tinyshed-tinystore 0.1.0rc1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tinyshed_tinystore-0.1.0rc1-py3-none-any.whl | Python 3 | none | any | Details |
Release files / tinyshed_tinystore-0.1.0rc1-py3-none-any.whl
| Download URL | tinyshed_tinystore-0.1.0rc1-py3-none-any.whl |
|---|---|
| Size | 96.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2c4e39cf3adb851276552330f0b5496d249e6e129f1aaee43fce427ffce97d73
|
|
BLAKE2b-256 checksum How to use checksums |
79b634b09261c6d21cb018cc1ac9f9eeae00ef0b432e9efce1b3c209e17e596c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 3, 2026.
Transparency log