DiskStore¶
SQLite-backed MutableMapping / Mapping storage
Fast disk storage built on top of the Python standard-library
sqlite3 module. Keys and
values are serialised via a pluggable configuration system supporting plain
blobs, JSON, NamedTuples, dataclasses and Pydantic models.
Features¶
- Pure-Python on top of the standard-library
sqlite3module, with an optional APSW accelerator - 100 % test coverage
- Thread-safe and process-safe (fork‑safe)
- Developed on Python 3.14, tested on CPython 3.10–3.14
- Tested using GitHub Actions on Linux and macOS
Quickstart¶
from diskstore import DiskStore
ds = DiskStore("/tmp/diskstore_quickstart.db")
ds["key"] = "my value"
print(ds["key"])
assert len(ds) == 1
assert "key" in ds
assert list(ds) == ["key"]
del ds["key"]
Basic operations¶
DiskStore implements the full MutableMapping interface:
from diskstore import DiskStore
ds = DiskStore("/tmp/diskstore_basic.db")
# set
ds["one"] = 1
ds["two"] = 2
# get
assert ds["one"] == 1
# contains
assert "two" in ds
# iteration
assert set(ds) == {"one", "two"}
# length
assert len(ds) == 2
# keys / values / items
assert sorted(ds.keys()) == ["one", "two"]
assert sorted(ds.values()) == [1, 2]
# delete
del ds["one"]
assert len(ds) == 1
Bulk writes¶
Each individual write (__setitem__, __delitem__, …) creates an implicit
SQLite transaction. Wrapping many writes in transact() eliminates that
overhead:
from diskstore import DiskStore
ds = DiskStore("/tmp/diskstore_bulk.db")
# slow: one implicit transaction per write
for i in range(100):
ds[i] = "value"
# fast: single explicit transaction
with ds.transact():
for i in range(100):
ds[i] = "value"
# update() is transactional by default
ds.update({i: "value" for i in range(100)})
transact() yields the active backend's cursor (sqlite3.Cursor, or
apsw.Cursor when APSW is installed) for callers that need direct SQL.
Nested calls are idempotent (the outer transaction is reused).
Auto-increment keys¶
When key_type=int is used, passing None as the key triggers SQLite
auto-increment via INTEGER PRIMARY KEY NULL → rowid:
from diskstore import DiskStore
from diskstore.config import BaseConfig
ds = DiskStore("/tmp/diskstore_autoinc.db", config=BaseConfig(key_type=int))
key = ds.add(None, "auto-generated")
assert key is not None
assert isinstance(key, int)
assert ds[key] == "auto-generated"
Other DiskStore methods¶
from diskstore import DiskStore
ds = DiskStore("/tmp/diskstore_misc.db")
ds["one"] = 1
ds["two"] = 2
# pop
assert ds.pop("one") == 1
assert ds.pop("missing", "default") == "default"
# popitem (last inserted item)
_key, _value = ds.popitem()
# setdefault
ds.setdefault("three", 3)
assert ds["three"] == 3
# clear
ds.clear()
assert len(ds) == 0
# check integrity (optional VACUUM)
ds["three"] = 3
warnings = ds.check()
assert warnings == []
ds.check(vacuum=True)
# get read-only instance (shares the same DB file)
ro = ds.get_readonly_instance()
assert "three" in ro
DiskRead - read-only access¶
DiskRead is a lightweight Mapping implementation that opens the database
read‑only:
from diskstore import DiskRead, DiskStore
from diskstore.config import BaseConfig
with DiskStore("/tmp/diskstore_autoinc.db", config=BaseConfig(key_type=int)) as wds:
wds.add(None, "auto-generated")
# context-manager support (opens and closes the connection)
with DiskRead("/tmp/diskstore_autoinc.db") as ds:
assert len(ds) > 0
for key, value in ds.items():
assert isinstance(key, int)
Query¶
DiskRead.query() supports WHERE, ORDER BY, LIMIT and OFFSET:
from diskstore import DiskRead, DiskStore
with DiskStore("/tmp/diskstore_bulk.db") as wds:
wds.update({i: str(i) for i in range(10)})
with DiskRead("/tmp/diskstore_bulk.db") as ds:
results = list(ds.query(where="_key > ?", parameters=(5,), limit=3))
assert len(results) == 3
assert results[0][0] == 6
Reversed iteration:
from diskstore import DiskRead, DiskStore
with DiskStore("/tmp/diskstore_bulk.db") as wds:
wds.update({i: str(i) for i in range(3)})
with DiskRead("/tmp/diskstore_bulk.db") as ds:
keys = list(reversed(ds))
assert keys == sorted(keys, reverse=True)
Configuration¶
The config system controls how keys and values are stored in SQLite columns.
BaseConfig - single BLOB column¶
The default - stores values as raw SQLite BLOBs:
from diskstore import DiskStore
from diskstore.config import BaseConfig
config = BaseConfig(key_type=str)
ds = DiskStore("/tmp/diskstore_config_base.db", config=config)
ds["msg"] = "hello"
assert ds["msg"] == "hello"
JsonConfig - JSON-serialised TEXT column¶
from diskstore import DiskStore
from diskstore.config import JsonConfig
config = JsonConfig(key_type=str)
ds = DiskStore("/tmp/diskstore_config_json.db", config=config)
ds["nested"] = {"a": [1, 2, 3]}
assert ds["nested"] == {"a": [1, 2, 3]}
NamedTupleConfig - one column per field¶
from typing import NamedTuple
from diskstore import DiskStore
from diskstore.config import NamedTupleConfig
class Point(NamedTuple):
x: float
y: float
config = NamedTupleConfig(Point, key_type=str)
ds = DiskStore("/tmp/diskstore_config_nt.db", config=config)
ds["origin"] = Point(0.0, 0.0)
pt = ds["origin"]
assert pt.x == 0.0 and pt.y == 0.0
DataclassConfig - one column per field¶
from dataclasses import dataclass
from diskstore import DiskStore
from diskstore.config import DataclassConfig
@dataclass
class Item:
name: str
price: float
config = DataclassConfig(Item, key_type=str)
ds = DiskStore("/tmp/diskstore_config_dc.db", config=config)
ds["widget"] = Item("Widget", 9.99)
item = ds["widget"]
assert item.name == "Widget" and item.price == 9.99
PydanticConfig - JSON-serialised model column¶
from pydantic import BaseModel
from diskstore import DiskStore
from diskstore.config import PydanticConfig
class Task(BaseModel):
title: str
done: bool = False
config = PydanticConfig(Task, key_type=str)
ds = DiskStore("/tmp/diskstore_config_pd.db", config=config)
ds["task1"] = Task(title="Write docs")
task = ds["task1"]
assert task.title == "Write docs"
assert task.done is False
StructtypeConfig - JSON-serialised BLOB column¶
from structtype import Struct
from diskstore import DiskStore
from diskstore.config import StructtypeConfig
class Task(Struct):
title: str
done: bool = False
config = StructtypeConfig(Task, key_type=str)
ds = DiskStore("/tmp/diskstore_config_st.db", config=config)
ds["task1"] = Task(title="Write docs")
task = ds["task1"]
assert task.title == "Write docs"
assert task.done is False
Configuration options¶
Every config class accepts:
| Option | Default | Description |
|---|---|---|
tablename |
"DiskStore" |
SQLite table name |
key_type |
bytes (BLOB) |
int, str, float, bytes or a SQLite type string |
timeout |
10.0 |
Busy timeout in seconds |
pragmas |
{} |
Extra PRAGMAs merged with built-in defaults |
auto_migrate |
True |
Auto-add missing columns at connection start |
Table migration¶
When auto_migrate=True (the default), _migrate_table() is called on every
first connection per-process. It creates the table if absent, then adds any
columns that exist in the config but are missing from the existing table.
This makes schema evolution seamless - define a new version of your dataclass with additional fields and old data is preserved with defaults:
from dataclasses import dataclass
from diskstore import DiskStore
from diskstore.config import DataclassConfig
# V1 schema: name + price
@dataclass
class ProductV1:
name: str
price: float
config = DataclassConfig(ProductV1, tablename="products")
ds = DiskStore("/tmp/diskstore_migrate_dc.db", config=config)
ds[1] = ProductV1("Widget", 9.99)
assert ds[1] == ProductV1("Widget", 9.99)
ds.close()
# V2 schema: adds in_stock with a default
# auto_migrate=True adds the column automatically
@dataclass
class ProductV2:
name: str
price: float
in_stock: bool = True
config_v2 = DataclassConfig(ProductV2, tablename="products")
ds2 = DiskStore("/tmp/diskstore_migrate_dc.db", config=config_v2)
# Old row gets the default for the new column
assert ds2[1] == ProductV2("Widget", 9.99, True)
# New rows use both old and new fields
ds2[2] = ProductV2("Gadget", 24.99, False)
assert ds2[2] == ProductV2("Gadget", 24.99, False)
Performance notes¶
DiskStore uses the Python standard-library sqlite3 module by default, so it
has no required third-party dependency. If APSW
is installed (pip install diskstore[apsw]) it is used automatically; force a
backend with the DISKSTORE_BACKEND environment variable (apsw or
sqlite3). The stdlib path requires SQLite 3.35 or newer (for
INSERT ... RETURNING); the version bundled with the running CPython release
is used. APSW bundles its own recent SQLite.
Default pragmas (src/diskstore/const.py):
| Pragma | Value | Effect |
|---|---|---|
journal_mode |
WAL |
Concurrent reads during writes |
page_size |
16 KB | Fewer B-tree pages and a smaller database (new databases only) |
cache_size |
32 MB (-32768, KiB) |
Page cache per connection |
mmap_size |
256 MB | Memory-mapped I/O |
synchronous |
NORMAL |
Balance speed / durability |
temp_store |
MEMORY |
Temp tables and indexes kept in memory |
auto_vacuum |
NONE |
No automatic vacuuming |
wal_autocheckpoint |
5000 pages (~82 MB at 16 KB) | WAL checkpoint threshold |
cache_size and wal_autocheckpoint are counted in pages, so they scale with
page_size. A negative cache_size is interpreted by SQLite as KiB and is
therefore independent of the page size.
APSW vs stdlib sqlite3¶
scripts/bench_ab.py compares the two backends of the current checkout by
running the same workloads in two subprocesses, one with
DISKSTORE_BACKEND=apsw and one with DISKSTORE_BACKEND=sqlite3. The ratio is
sqlite3 / apsw, so values above 1.0 mean APSW is faster. Sample results
(Python 3.14, SQLite 3.53.1, 20 000 ops, 10 000 keys, 1 KB values, 4 processes,
median of 3 rounds):
| Workload | sqlite3 / apsw |
|---|---|
set (single key) |
1.00 – 1.08× |
get (single key) |
1.24 – 1.29× |
delete (single key) |
0.96 – 1.11× |
update (bulk upsert) |
1.05 – 1.20× |
set inside transact() |
1.19 – 1.38× |
concurrent get (4 procs) |
1.16 – 1.21× |
The standard-library driver is near parity for writes and up to ~40 % slower
for reads and transaction-batched writes; installing the optional apsw extra
recovers that. Run make bench-ab to reproduce on your machine.
Benchmark scripts are available at scripts/benchmark.py,
scripts/benchmark_core.py and scripts/bench_ab.py (apsw vs stdlib
sqlite3 A/B comparison).
License¶
DiskStore is distributed under the terms of the
BSD-3-Clause license.
Copyright 2025-2026 Wolfgang Langner