Rows of one validated dataclass, in memory, shadowed by a JSON file you can open and edit. The class is the database.
The rows live in a dict and every read answers from there; a writer thread
carries them to a file named for the class and the fingerprint of its compiled
schema — Task.a50534e4.json. Change one character of the contract, a type, a
limit, a default, a Label, and it is a different database: a new file, with
the old one left beside it, whole and readable.
That is the whole of it, and it is what buys the guarantee below: if the file opens, every row in it is valid against this exact schema.
pip install pytypehintstoreThat brings pytypehint with it, the
only dependency, pinned to the exact version this release was built against.
Stdlib otherwise. Python 3.11+, py.typed included.
from dataclasses import dataclass
from enum import Enum
from typing import Annotated
from pytypehint import Max, Min
from pytypehintstore import store_of
class Priority(Enum):
LOW = "low"
HIGH = "high"
@dataclass
class Task:
title: Annotated[str, Min(1), Max(80)]
priority: Priority = Priority.LOW
done: bool = False
tasks = store_of(Task, "data")
tasks.path.name # 'Task.a50534e4.json'
first = tasks.add(Task(title="Buy milk", priority=Priority.HIGH)) # 1
second = tasks.add(Task(title="Write the store")) # 2
tasks.get(first) # Task(title='Buy milk', priority=<Priority.HIGH: 'high'>, done=False)
tasks.put(second, Task(title="Write the store", done=True))
tasks.remove(first)
len(tasks) # 1
second in tasks # True
tasks.all() # [(2, Task(title='Write the store', ...))]
tasks.close() # the file is on disk when this returnsReopen it and the rows are there with the same ids. Ids are spent, never
recycled, so the next add gives 3.
If a store opens, every row it holds was rebuilt through the schema you are holding right now. There is no such thing as a row from an older version: those live in an older file, under an older fingerprint.
add and put accept a row by making the round trip it will make anyway — the
instance is encoded to its transport form and rebuilt through the schema — so
what the store keeps always came out of the constructor and is known to survive
the file.
The store has no second validation system. A row that does not validate fails with the core's own error, word for word:
tasks.add(Task(title=""))
# SchemaValueError: title: too short: 0 chars, minimum 1That is the same line struct_of(Task).build({"title": ""}) produces. Only one
message belongs to the store itself, because only the store can be asked this:
tasks.add("not a task")
# SchemaTypeError: expected Task, got strThe instance the store keeps is not the one you passed; mutating yours afterwards changes nothing inside.
One question the store asks on its own behalf, after the core has spoken: a row
has to fit in a UTF-8 file. A lone surrogate is a str Python allows and no
encoder can write, so it is refused at add rather than discovered at close:
tasks.add(Task(title="\ud800"))
# SchemaValueError: cannot be written as UTF-8: 'utf-8' codec can't encode …The property, stated plainly: if the core compiles your schema, the store
persists it without loss or fails loudly — at add, or at store_of before a
lockfile is taken. No exception, and no asterisk. The stress campaign of 1150
generated schemas and four adversarial fronts found one, a union of lists
differing only in a constraint, and 1.0.0 closed it by handing the question to
the core's own router rather than answering it here.
The file is {ClassName}.{fingerprint}.json. The fingerprint is eight hex of
the sha256 of the compiled schema, rendered as deterministic text.
Everything in the schema counts — types, limits, defaults, the order of the
fields, and the notation atoms (Label, Description, Placeholder, …) that
change nothing a file can carry. No exceptions and no small print: a contract
without special cases fits in one sentence and is tested in three lines.
Change the class and you get a new, empty database. The old file stays where it was, untouched and readable, named for the schema that wrote it. Moving rows across is a script over readable JSON — a minute of work for you or an agent — and it is deliberately outside this library, because a migration that runs automatically is a migration nobody read.
The file carries the transport form, the same shapes an HTTP client would send:
{
"v": 1,
"next_id": 3,
"rows": {
"1": {
"title": "Buy milk",
"due": "2026-08-01",
"priority": "HIGH",
"note": { "body": "oat" }
}
}
}A date or a time is ISO text, an enum is its member name, a nested dataclass
is an object, a list is a list. Never a repr of a Python value.
Edit it, and the next load either accepts your edit or says exactly what is
wrong with it. Every one of these is a StoreLoadError raised by store_of,
before a single row is served:
| What is broken | What you get |
|---|---|
| The file does not exist | An empty store — not an error |
| Not UTF-8 | …json: not valid UTF-8: … |
| Not JSON | …json: not valid JSON: Expecting property name … (char 1) |
| The root is not an object | …json: expected an object, got list |
v is not the format version |
…json: unknown format version 2, expected 1 |
rows is not an object |
…json: rows must be an object, got list |
| A row id is not an integer | …json: row id '--5' is not an integer |
Two ids are the same integer ("1", "01") |
…json: row id '01' is already taken by another key |
| A row does not validate | …json: row 1: note: body: too short: 0 chars, minimum 1 |
The last one is the core speaking, prefixed with the row it happened in. A row
missing a field reports exactly that — row 1: missing key(s): title — and
nothing more: there is no advice to add a default, because that would fork the
database rather than fix the row.
next_id is a hint, not an authority: if the file names one that would collide
with a row it also carries, the highest id present wins.
Most values need no help: the JSON type says which option of a union they belong to. The wrapper appears only when two options land on the same JSON type, and it survives decoding only where the core still needs it.
when: str | date # {"$type": "date", "$value": "2026-08-01"}
terms: list[str] | list[int] # {"$type": "list[int]", "$value": [1, 2]}Both are text and both are lists, so in each case the option gets named. On the
way back, the first wrapper is consumed — the core tells str and date apart
by Python type — and the second is kept, because to the core both options are
list and only the name separates them. Dataclasses in a union name the variant
inside the object instead: {"$type": "Square", "side": 2}.
Anything the codec cannot read as one single thing travels intact, so the error you see is the core's, with its path and its words.
Which option gets named is not the store's opinion. Writing asks
pytypehint.validation.value_branch — the core's own router, the one validation
itself uses — so the branch the file names and the branch the schema would pick
are the same answer to the same question. Reading back is the core's outright:
schema.decode, published in 1.0.0. That router is internal to the core and
carries no public promise, which is why the dependency is pinned to an exact
version rather than a floor.
A write mutates the dict at once and marks the store unwritten. The writer
thread dumps it debounce seconds later, and every write pushes that deadline
back, so a burst costs one dump.
The dump is atomic: the text goes to a .part file, is flushed and fsynced,
then replaces the real file with os.replace. The file goes from one whole JSON
to the next, never a half of either. A failed dump leaves nothing of itself
behind, keeps the state unwritten, and is tried again — it never kills the
writer and never passes for a success. Before each replace the previous file is
copied aside with a nanosecond stamp, and copies beyond keep are pruned. Each
database prunes its own copies: the fingerprint is in the name.
close() stops the writer, dumps what is pending, and releases the lock. When it
returns the file is on disk — or it raised, and it raised at every caller that
was closing the same store. The lock is released either way.
If you never call close(), an atexit hook does it on a normal interpreter
exit. It does not run on os._exit, on a hard kill, or on a power cut; what is
on disk then is the last dump that landed, whole.
Opening a store writes Task.a50534e4.json.lock beside the file, holding the
owner's PID, and keeps it open while the store is open.
A second store on the same file — in this process or any other — fails at startup:
…Task.a50534e4.json is owned by process 18220. A store belongs to one process;
you are probably running several workers. Run a single worker, or reach for a
database server — this is not one.
A lockfile left by a crash names a PID that no longer runs, and the next process
takes it over; so does one whose contents say nothing. Two processes racing for
the same orphan cannot both end up owning it: the operating system settles it,
not an agreement between readers — an open handle Windows will not unlink, an
advisory flock the kernel drops when a process dies on POSIX. Two different
classes in one directory are two files and two locks, and never meet.
store = store_of(cls, directory, *, debounce=2.0, keep=5)cls is the dataclass whose rows this store holds. directory is where the file
goes; it is created if missing, and a file sitting in its place is an error.
debounce is how long a burst of writes may keep pushing the dump back. keep
is how many rotated copies survive.
| Method | What it does |
|---|---|
add(obj) -> int |
Validates and stores a row; returns its new id. A rejected row does not spend an id. |
get(row_id) |
The row under that id. KeyError if there is none. |
put(row_id, obj) |
Validates and replaces the row under an existing id. KeyError if there is none. |
remove(row_id) |
Takes the row out. KeyError if there is none. The id is not reused. |
all() -> list[tuple[int, obj]] |
Every row, sorted by id. |
len(store) |
How many rows. |
row_id in store |
Membership by id. |
store.path |
The Path of the file this class landed on. |
close() |
Dumps what is pending, stops the writer, releases the lock. Idempotent. |
| Exception | When |
|---|---|
StoreLockedError |
Another live process — or this one — already owns the file |
StoreLoadError |
The file exists and could not be read back into rows |
StoreError |
An operation on a closed store, or a directory that is not one |
Validation failures are not among them: SchemaTypeError and SchemaValueError
travel out of add and put exactly as the core raised them.
- One process. The lock enforces it. Two live stores behave the same
everywhere; what differs is a lockfile nobody is holding. On POSIX the
flockis the evidence, so a file naming a live process that is not holding it — a crash plus a recycled pid — is taken over, and a recycled pid cannot lock a path out. On Windows the pid is the evidence, so that file is respected and the way out is to delete it by hand. And deleting it while a store holds it is refused by Windows but allowed by POSIX, where the exclusion is then lost until both processes end: delete a lockfile only when nothing is holding it. - Everything in memory. The file is read once at startup and rewritten whole on every dump. Thousands of rows, not millions.
- Ids are auto-incrementing ints, never recycled.
- No queries.
all()and filter it yourself. - The file is never re-read while the process runs. Editing it under a running store changes nothing, and the next dump overwrites your edit.
IsPasswordencrypts nothing. The value is written in the clear, like any other string.FileHintstores the path, not the file. The core validates the extension, which the text of the value settles by itself, and asks the filesystem nothing — so a row whose file was moved, deleted or grown comes back exactly as it was stored. Whether the file is still there is a question for whoever opens it.int | floatis ambiguous in a hand-edited file. A bare3loads as anint; write3.0if you meant a float.- A date is
YYYY-MM-DDand a time isHH:MM[:SS], and no other spelling loads.date.fromisoformataccepts far more than that, so"20260101"and"2026-W01-1"used to load and then be rewritten in the canonical form on the next dump — a week date quietly becoming the Monday it names, in another year. The core pinned the spellings in 1.0.0, so a file holding one of the others is now refused at load with the core's own message rather than read and rewritten behind your back. Write the canonical form. - A wrong Python type whose text is valid transport is rewritten, not
refused. With
due: date,Task(due="2026-08-01")is accepted and the row ends up holdingdate(2026, 8, 1). get()andall()hand back the store's own rows, not copies. Mutating what you get reaches inside, unvalidated. Build a new instance andputit.- The debounce has no ceiling. Writes arriving faster than
debouncekeep pushing the dump back for as long as they keep arriving. - A failed dump is retried silently. Nothing logs it; it surfaces at
close(), or as a traceback from the exit hook. The library imposes no logger. - Upgrading
pytypehintor Python can move the fingerprints. The pinned test intests/test_identity.pycatches it. It is declared as a breaking change, never absorbed — an absorbed one would silently rename every database. - A row the file cannot carry is refused at
add. A lone surrogate is astrPython allows and no encoder can write. - Duplicate row ids in a hand-edited file: the last one wins.
json.loadsdrops the earlier ones before the store sees there were two, and there is no defence short of parsing the file ourselves. - Around 120 levels of nesting on 3.11, around 245 on 3.12 and later. Deeper
than that, opening the store raises
RecursionError— loudly, and before the lockfile is taken, so nothing is left behind. The gap between the two is the interpreter's, not the store's: before 3.12 every comprehension on the way down takes a stack frame of its own. The core itself compiles to about 247 on either. - Rows are rebuilt one by one on load. 5 000 rows of an eight-field dataclass take about half a second to reopen and 1.7 MB on disk; adding them costs about 0.12 ms each.
pytypehintcompiles standard Python type hints into strict, inspectable schemas. It is the validation this store defers to, and the schema it fingerprints.pytypehintwebcompiles the same schemas into a browser runtime for forms — the transport this store writes to disk is the shape it speaks.FuncToWebexposes typed Python functions through generated web interfaces. A store is the small persistence such an app usually wants, without adding a database to it.