Default storage tech per use case
Personal, Python-first, local-first projects. Applies when starting a storage/caching need without
an explicit request to analyze alternatives — pick from this table, don't re-litigate from scratch
each session. Deviating is fine when a category genuinely doesn't fit or scale requirements outgrow
the default (see each category's "Escalate to" line) — the point is to stop a fresh session/model
from silently picking something different for no reason, not to forbid judgment calls.
Selection criteria for every entry below: MIT/Apache-2.0/BSD-style permissive license only;
popular and actively maintained (verified via real GitHub/PyPI activity, not vibes); testable fully
inside a plain pytest run — in-memory or tmp_path-scoped, no Docker/cloud account/CI services;
low-boilerplate API a coding agent can use correctly without much ceremony or indirection. Picks
favor the best-fit tool per concern over minimizing the number of technologies in a project — don't
force a consolidation the categories below don't call for.
Cash in the testability criterion: don't mock these. The whole point of picking a store the
suite can start and stop is that tests run the real one, so a double here throws away what the
choice bought. python-conventions, "Don't double anything the suite can run for real", states the
rule and the boundary.
Security is deliberately not a selection factor. Every default here is chosen for local,
personal-scale use — nothing on this page should be treated as a production/multi-tenant/internet-
facing recommendation. Each category's "Escalate to" line is the pick for that situation instead.
In-process ephemeral state
- Snippet:
references/snippets/in-process-state.py
- Default: plain stdlib (
threading.Lock, time.monotonic, a dict) — no library
- Why: no persistence needed; a library would be pure overhead for "hold one number, guarded by one
lock"
- Escalate to: n/a — once it needs to survive a restart, it's the Cache or Relational category
instead
Cache (TTL / eviction)
- Snippet:
references/snippets/cache.py
- Default:
diskcache (Apache-2.0, pure Python)
- Why: disk-backed, handles TTL/eviction so you don't hand-roll it; trivial pytest
tmp_path
fixture
- Escalate to: Redis — once multiple processes/machines need to share one cache
Relational — simple (few tables, KV-shaped)
- Snippet:
references/snippets/relational-simple.py
- Default: stdlib
sqlite3, raw SQL
- Why: zero dependency, no ORM ceremony for a shape this small;
:memory: or tmp_path in pytest
- Escalate to: Postgres
Relational — complex / OLTP (many tables, real joins, migrations, frequent writes)
- Snippet:
references/snippets/relational-oltp.py
- Default:
sqlalchemy + alembic (both MIT)
- Why: real Engine/Session/declarative-model ceremony, but it buys migration and relationship safety
that nothing lighter offers for a genuinely transactional multi-table shape
- Define the column vocabulary once, via the declarative base's
type_annotation_map for plain
types and Annotated aliases for the ones needing per-column arguments. Every Mapped[Decimal]
in every model then resolves the same way, so no column opts out by being written from memory in
another file. This is also the honest answer to "why the ORM rather than Core" — Core gives you
the same SQL with the vocabulary spelled out at each column instead.
- Escalate to: Postgres
- The SQLite tier silently corrupts
Decimal and drops tzinfo, and both are invisible on the
Postgres you escalate to — which matters here specifically, because "testable fully inside a plain
pytest run" is what puts SQLite under every project following this entry. Measured against
SQLAlchemy 2.0.52:
Numeric round-trips a Decimal through a float, with no warning:
1234567890123456789.000000001 comes back 1234567890123456768.0000000000. The mechanism is in
the library — engine/default.py sets supports_native_decimal = False and the SQLite dialect
does not override it, so sqltypes.py takes the branch commented "DBAPI returns floats,
convert", quantized to scale 10. A TypeDecorator storing the exact string is the fix.
- A timezone-aware datetime comes back naive. Not a SQLite limitation:
dialects/sqlite/base.py's
DATETIME.bind_processor never reads tzinfo at all.
- The trap is how they hide. A first probe of the
Decimal case passes on a ten-digit value,
which survives a float intact — so a project whose fixtures use realistic amounts concludes
Numeric is fine and ships. Probe with a value wide enough to fail.
- Pass
sqlite_strict=True in __table_args__ so the test dialect stops being laxer than the
Postgres it stands in for — the failure mode a test-only dialect invites. It is not a free
extra keyword, and the snippet shows the whole shape: STRICT accepts only
INT/INTEGER/REAL/TEXT/BLOB/ANY, so create_all fails outright on NUMERIC(38, 10),
on DATETIME, and on the VARCHAR(64) that a plain String(64) renders (all three measured on
2.0.52). String(64).with_variant(Text(), "sqlite") keeps the real bound on Postgres, and the
Decimal/datetime TypeDecorators above are what make the other two columns legal. Adopting
STRICT and closing the two traps are therefore the same piece of work, which is the argument for
doing both at once rather than neither.
- Alembic's SQLite batch mode is a data-integrity footgun. On SQLite,
batch_alter_table
implements a change by recreating the table, which drops that table's triggers — and Alembic
has no trigger awareness anywhere (a grep for "trigger" across the package returns one unrelated
comment). It also skips a CheckConstraint that is both reflected and unnamed, per
batch.py's own TODO. Two mitigations, and either is valid: name every constraint, or
decide that the SQLite tier never migrates at all and is always created from current metadata.
Analytical / OLAP (read/aggregate-heavy queries over structured data)
- Snippet:
references/snippets/analytical-olap.py
- Default:
duckdb (MIT — IP held by a nonprofit foundation specifically to keep it MIT "in
perpetuity")
- Why:
duckdb.connect(":memory:") gives native SQL over a plain connection, zero ORM ceremony —
much lower-boilerplate than SQLAlchemy for this shape, and genuinely fast at joins/aggregation.
Single-writer (fine for personal-scale local use); a multi-writer "Quack" protocol shipped May
2026 but is still new
- Escalate to: Postgres+DuckDB extension, or MotherDuck
Document store (schemaless / semi-structured JSON)
- Snippet:
references/snippets/document-store.py
- Default:
tinydb (MIT, pure Python)
- Why: real dict-like API (
db.insert({...}), db.search(Query().field == x)) — no SQL anywhere;
MemoryStorage() or tmp_path for pytest. Maintainer calls it "maintenance mode"
(feature-complete and stable) — still gets bugfix releases, not abandoned
- Alternative:
sqlitedict (Apache-2.0) for pure key→object storage with no field queries needed —
flag its multi-year commit gap before reaching for it
- Escalate to: MongoDB
Full-text search
- Snippet:
references/snippets/full-text-search.py
- Default: SQLite
FTS5 virtual tables — ships with stdlib sqlite3, zero new dependency, built-in
bm25() ranking
- Alternative:
bm25s (MIT) for a standalone RAG-style scorer decoupled from a datastore; tantivy
(MIT, Python bindings to the Rust tantivy search engine — pip install tantivy, not
tantivy-py, which is a different, stale package) for heavier Lucene-like search once FTS5's
feature set is genuinely too thin
- Avoid:
Whoosh — long-unmaintained
- Escalate to: Elasticsearch or Meilisearch (both need a running server, so are excluded as local
defaults)
Vector / embedding similarity search
- Snippet:
references/snippets/vector-search.py
- Default:
qdrant-client (Apache-2.0)
- Why:
QdrantClient(":memory:") or QdrantClient(path=tmp_path) needs no server at all, and
escalating later is a literal constructor-arg swap (url=.../cloud creds) — same class, same
methods, verified against Qdrant's own docs. Handles vectors + metadata + persistence in one API
- Alternative:
chromadb (Apache-2.0) — far bigger community/mindshare, arguably an even simpler
first-touch API (add()/query()); reasonable pick if onboarding ease matters more than a
verified no-rewrite escalation path
- Escalate to: Qdrant Cloud (client code unchanged) or Pinecone/Weaviate
Background job / message queue
- Snippet:
references/snippets/job-queue.py
- Default:
huey (MIT)
- Why:
SqliteHuey(..., immediate=True) runs @huey.task() functions synchronously in-process — no
consumer subprocess, no orchestration, a plain pytest test just calls the function directly. Small
decorator-based API, 15-year track record, near-zero open-issue backlog
- Escalate to: Celery/RQ/Dramatiq — all need a real broker (Redis/RabbitMQ), which is the point once
you need multiple worker processes/machines
Cron / scheduled recurring tasks
- Snippet:
references/snippets/cron-scheduler.py
- Default:
apscheduler 3.x (MIT)
- Why:
SQLAlchemyJobStore pointed at a sqlite:/// URL persists schedules across restarts, no
external broker, usable purely as a queue if scheduling isn't even needed. Genuine bus-factor risk
to know about, not a reason to avoid it outright: single dominant maintainer (1,134 commits vs.
next-highest contributor's 6), ~2-year-old unreviewed PRs, and v4 has been in alpha since 2020
with no stable release — 3.x is still the only practical choice and remains actively patched
(releases as recently as Jun 2026)
- Alternative:
schedule (MIT) — zero-dependency, trivial in-process API, but no persistence (a
restart loses all schedules) and itself hasn't been pushed since May 2024; a lightweight escape
hatch for pure in-process cases, not a governance-driven replacement
- Escalate to: a managed scheduler (e.g. cloud cron) once recurring jobs need to survive beyond one
machine
Pub/sub / event streaming
- Snippet:
references/snippets/pubsub.py
- Default:
blinker (MIT) — in-process signal/observer dispatch (signal.connect(receiver),
signal.send(sender))
- Why: the same library Flask itself uses for signals; zero ceremony, fans out to multiple
subscribers in-process, no server, trivially testable
- Escalate to: NATS (Apache-2.0) — genuinely different tool, not a bigger version of this one: needs
a separately-provisioned
nats-server process, and no official/maintained Python pytest-fixture
package exists for local testing (the two community attempts are both abandoned), so it doesn't
qualify as a local default here even though it's a strong product once you're actually running a
server
Graph data
- Snippet:
references/snippets/graph-data.py
- Default:
ladybug (MIT, pip install ladybug) — embedded, Cypher query language, no server
- Why: this is the actively-maintained continuation of Kuzu, forked by the community 3 days after
Kuzu's Oct 2025 archival (Apple acquisition) — same underlying engine, new stewardship, not a
rewrite. Daily commits, MIT license, named active maintainers. Still under a year old as a project
name, so treat as promising-but-young rather than as battle-tested as, say, SQLite
- Lightweight alternative: for relationship modeling that doesn't need real graph-traversal
performance or a query language, a plain
edges(src, dst, relation) table in your relational
store is still simpler — reach for ladybug specifically when you need actual graph algorithms/
traversal at more than toy scale. Pair either with networkx (BSD-3-Clause) for in-memory graph
algorithms over already-loaded data
- Escalate to: Neo4j Aura, Amazon Neptune
Blob / object storage
- Snippet:
references/snippets/blob-storage.py
- Default: plain
pathlib.Path file writes — no library
- Why: zero dependency, trivial
tmp_path testing; adding an abstraction for cloud storage you
don't have a concrete plan for yet is exactly the case YAGNI is for
- Escalate to:
fsspec (BSD-3-Clause) + s3fs/gcsfs once cloud storage is an actual plan, not a
maybe — same fs.open()/fs.ls() calls, only the protocol string changes
Time-series data
- Snippet:
references/snippets/time-series.py
- Default:
duckdb (MIT) — same tool as the Analytical/OLAP category above
- Why: DuckDB's window functions,
time_bucket, gap-filling, and ASOF joins are genuinely strong
for time-series analysis and meaningfully cut boilerplate versus hand-rolling the same queries in
raw SQL — the better tool for this concern specifically, independent of whether a project also
needs it for OLAP work
- Lightweight alternative: a plain SQLite table with a timestamp-indexed column, if the need is
truly just "store timestamped rows and filter by range," with no resampling/windowing
- Escalate to: InfluxDB, TimescaleDB
Editing this skill
This file is copied into ~/.agents/skills/db-defaults at install time, never symlinked, so
editing the deployed copy is local drift — the exact thing this skill exists to prevent, and it
reaches no other machine. Edit the source in the repo this was installed from, push, and re-run
skills add <that source> --global --skill db-defaults to refresh every project's copy. If you
installed it from someone else's repo rather than your own fork, the source is theirs: open an issue
or a pull request there instead.
Starter snippets
references/snippets/ has one real, ruff-clean, self-contained Python file per category above —
each a verified pip install command in its docstring plus a working test_* function showing the
pytest-local pattern, directly copy-pasteable rather than a tutorial to adapt. Each category's
"Snippet:" line above links straight to its own file, so there's no need to open the others. This is
also where two real naming traps caught while writing them are documented at the point they matter:
full-text-search.py (the tantivy vs tantivy-py PyPI mixup) and cron-scheduler.py (the
nonexistent SQLiteJobStore class name).
Full rationale
See references/rationale.md — the GitHub/PyPI evidence, the options
that were considered and rejected per category, and the reasoning behind every branch/alternative
above.
1---2name: db-defaults3description: Use when adding local data persistence to a Python project — caching, relational storage (simple, complex/OLTP, or analytical/OLAP), document storage, full-text search, vector/embedding search, background job queues, cron/scheduled tasks, pub/sub/event streaming, graph data, blob storage, or time-series data — and no explicit "evaluate the best DB for this" request was made. Gives the default technology per category, chosen for permissive licensing, pytest-local testability with no docker/cloud, and low-boilerplate LLM-agent-friendly APIs, so picks stay consistent across projects instead of drifting session to session.4---56# Default storage tech per use case78Personal, Python-first, local-first projects. Applies when starting a storage/caching need without9an explicit request to analyze alternatives — pick from this table, don't re-litigate from scratch10each session. Deviating is fine when a category genuinely doesn't fit or scale requirements outgrow11the default (see each category's "Escalate to" line) — the point is to stop a fresh session/model12from silently picking something different for no reason, not to forbid judgment calls.1314**Selection criteria for every entry below**: MIT/Apache-2.0/BSD-style permissive license only;15popular and actively maintained (verified via real GitHub/PyPI activity, not vibes); testable fully16inside a plain `pytest` run — in-memory or `tmp_path`-scoped, no Docker/cloud account/CI services;17low-boilerplate API a coding agent can use correctly without much ceremony or indirection. Picks18favor the best-fit tool per concern over minimizing the number of technologies in a project — don't19force a consolidation the categories below don't call for.2021**Cash in the testability criterion: don't mock these.** The whole point of picking a store the22suite can start and stop is that tests run the real one, so a double here throws away what the23choice bought. `python-conventions`, "Don't double anything the suite can run for real", states the24rule and the boundary.2526**Security is deliberately not a selection factor.** Every default here is chosen for local,27personal-scale use — nothing on this page should be treated as a production/multi-tenant/internet-28facing recommendation. Each category's "Escalate to" line is the pick for that situation instead.2930## In-process ephemeral state3132- Snippet: [`references/snippets/in-process-state.py`](references/snippets/in-process-state.py)33- Default: plain stdlib (`threading.Lock`, `time.monotonic`, a dict) — no library34- Why: no persistence needed; a library would be pure overhead for "hold one number, guarded by one35 lock"36- Escalate to: n/a — once it needs to survive a restart, it's the Cache or Relational category37 instead3839## Cache (TTL / eviction)4041- Snippet: [`references/snippets/cache.py`](references/snippets/cache.py)42- Default: `diskcache` (Apache-2.0, pure Python)43- Why: disk-backed, handles TTL/eviction so you don't hand-roll it; trivial pytest `tmp_path`44 fixture45- Escalate to: Redis — once multiple processes/machines need to share one cache4647## Relational — simple (few tables, KV-shaped)4849- Snippet: [`references/snippets/relational-simple.py`](references/snippets/relational-simple.py)50- Default: stdlib `sqlite3`, raw SQL51- Why: zero dependency, no ORM ceremony for a shape this small; `:memory:` or `tmp_path` in pytest52- Escalate to: Postgres5354## Relational — complex / OLTP (many tables, real joins, migrations, frequent writes)5556- Snippet: [`references/snippets/relational-oltp.py`](references/snippets/relational-oltp.py)57- Default: `sqlalchemy` + `alembic` (both MIT)58- Why: real Engine/Session/declarative-model ceremony, but it buys migration and relationship safety59 that nothing lighter offers for a genuinely transactional multi-table shape60- **Define the column vocabulary once**, via the declarative base's `type_annotation_map` for plain61 types and `Annotated` aliases for the ones needing per-column arguments. Every `Mapped[Decimal]`62 in every model then resolves the same way, so no column opts out by being written from memory in63 another file. This is also the honest answer to "why the ORM rather than Core" — Core gives you64 the same SQL with the vocabulary spelled out at each column instead.65- Escalate to: Postgres66- **The SQLite tier silently corrupts `Decimal` and drops `tzinfo`**, and both are invisible on the67 Postgres you escalate to — which matters here specifically, because "testable fully inside a plain68 `pytest` run" is what puts SQLite under every project following this entry. Measured against69 SQLAlchemy 2.0.52:70 - `Numeric` round-trips a `Decimal` through a float, with no warning:71 `1234567890123456789.000000001` comes back `1234567890123456768.0000000000`. The mechanism is in72 the library — `engine/default.py` sets `supports_native_decimal = False` and the SQLite dialect73 does not override it, so `sqltypes.py` takes the branch commented "DBAPI returns floats,74 convert", quantized to scale 10. A `TypeDecorator` storing the exact string is the fix.75 - A timezone-aware datetime comes back naive. Not a SQLite limitation: `dialects/sqlite/base.py`'s76 `DATETIME.bind_processor` never reads `tzinfo` at all.77 - The trap is how they hide. A first probe of the `Decimal` case _passes_ on a ten-digit value,78 which survives a float intact — so a project whose fixtures use realistic amounts concludes79 `Numeric` is fine and ships. Probe with a value wide enough to fail.80- **Pass `sqlite_strict=True`** in `__table_args__` so the test dialect stops being laxer than the81 Postgres it stands in for — the failure mode a test-only dialect invites. It is **not** a free82 extra keyword, and the snippet shows the whole shape: STRICT accepts only83 `INT`/`INTEGER`/`REAL`/`TEXT`/`BLOB`/`ANY`, so `create_all` fails outright on `NUMERIC(38, 10)`,84 on `DATETIME`, and on the `VARCHAR(64)` that a plain `String(64)` renders (all three measured on85 2.0.52). `String(64).with_variant(Text(), "sqlite")` keeps the real bound on Postgres, and the86 `Decimal`/`datetime` `TypeDecorator`s above are what make the other two columns legal. Adopting87 STRICT and closing the two traps are therefore the same piece of work, which is the argument for88 doing both at once rather than neither.89- **Alembic's SQLite batch mode is a data-integrity footgun.** On SQLite, `batch_alter_table`90 implements a change by recreating the table, which **drops that table's triggers** — and Alembic91 has no trigger awareness anywhere (a grep for "trigger" across the package returns one unrelated92 comment). It also skips a `CheckConstraint` that is **both reflected and unnamed**, per93 `batch.py`'s own `TODO`. Two mitigations, and either is valid: **name every constraint**, or94 decide that the SQLite tier never migrates at all and is always created from current metadata.9596## Analytical / OLAP (read/aggregate-heavy queries over structured data)9798- Snippet: [`references/snippets/analytical-olap.py`](references/snippets/analytical-olap.py)99- Default: `duckdb` (MIT — IP held by a nonprofit foundation specifically to keep it MIT "in100 perpetuity")101- Why: `duckdb.connect(":memory:")` gives native SQL over a plain connection, zero ORM ceremony —102 much lower-boilerplate than SQLAlchemy for this shape, and genuinely fast at joins/aggregation.103 Single-writer (fine for personal-scale local use); a multi-writer "Quack" protocol shipped May104 2026 but is still new105- Escalate to: Postgres+DuckDB extension, or MotherDuck106107## Document store (schemaless / semi-structured JSON)108109- Snippet: [`references/snippets/document-store.py`](references/snippets/document-store.py)110- Default: `tinydb` (MIT, pure Python)111- Why: real dict-like API (`db.insert({...})`, `db.search(Query().field == x)`) — no SQL anywhere;112 `MemoryStorage()` or `tmp_path` for pytest. Maintainer calls it "maintenance mode"113 (feature-complete and stable) — still gets bugfix releases, not abandoned114- Alternative: `sqlitedict` (Apache-2.0) for pure key→object storage with no field queries needed —115 flag its multi-year commit gap before reaching for it116- Escalate to: MongoDB117118## Full-text search119120- Snippet: [`references/snippets/full-text-search.py`](references/snippets/full-text-search.py)121- Default: SQLite `FTS5` virtual tables — ships with stdlib `sqlite3`, zero new dependency, built-in122 `bm25()` ranking123- Alternative: `bm25s` (MIT) for a standalone RAG-style scorer decoupled from a datastore; `tantivy`124 (MIT, Python bindings to the Rust `tantivy` search engine — `pip install tantivy`, not125 `tantivy-py`, which is a different, stale package) for heavier Lucene-like search once FTS5's126 feature set is genuinely too thin127- Avoid: `Whoosh` — long-unmaintained128- Escalate to: Elasticsearch or Meilisearch (both need a running server, so are excluded as local129 defaults)130131## Vector / embedding similarity search132133- Snippet: [`references/snippets/vector-search.py`](references/snippets/vector-search.py)134- Default: `qdrant-client` (Apache-2.0)135- Why: `QdrantClient(":memory:")` or `QdrantClient(path=tmp_path)` needs no server at all, and136 escalating later is a literal constructor-arg swap (`url=...`/cloud creds) — same class, same137 methods, verified against Qdrant's own docs. Handles vectors + metadata + persistence in one API138- Alternative: `chromadb` (Apache-2.0) — far bigger community/mindshare, arguably an even simpler139 first-touch API (`add()`/`query()`); reasonable pick if onboarding ease matters more than a140 verified no-rewrite escalation path141- Escalate to: Qdrant Cloud (client code unchanged) or Pinecone/Weaviate142143## Background job / message queue144145- Snippet: [`references/snippets/job-queue.py`](references/snippets/job-queue.py)146- Default: `huey` (MIT)147- Why: `SqliteHuey(..., immediate=True)` runs `@huey.task()` functions synchronously in-process — no148 consumer subprocess, no orchestration, a plain pytest test just calls the function directly. Small149 decorator-based API, 15-year track record, near-zero open-issue backlog150- Escalate to: Celery/RQ/Dramatiq — all need a real broker (Redis/RabbitMQ), which is the point once151 you need multiple worker processes/machines152153## Cron / scheduled recurring tasks154155- Snippet: [`references/snippets/cron-scheduler.py`](references/snippets/cron-scheduler.py)156- Default: `apscheduler` 3.x (MIT)157- Why: `SQLAlchemyJobStore` pointed at a `sqlite:///` URL persists schedules across restarts, no158 external broker, usable purely as a queue if scheduling isn't even needed. Genuine bus-factor risk159 to know about, not a reason to avoid it outright: single dominant maintainer (1,134 commits vs.160 next-highest contributor's 6), ~2-year-old unreviewed PRs, and v4 has been in alpha since 2020161 with no stable release — 3.x is still the only practical choice and remains actively patched162 (releases as recently as Jun 2026)163- Alternative: `schedule` (MIT) — zero-dependency, trivial in-process API, but no persistence (a164 restart loses all schedules) and itself hasn't been pushed since May 2024; a lightweight escape165 hatch for pure in-process cases, not a governance-driven replacement166- Escalate to: a managed scheduler (e.g. cloud cron) once recurring jobs need to survive beyond one167 machine168169## Pub/sub / event streaming170171- Snippet: [`references/snippets/pubsub.py`](references/snippets/pubsub.py)172- Default: `blinker` (MIT) — in-process signal/observer dispatch (`signal.connect(receiver)`,173 `signal.send(sender)`)174- Why: the same library Flask itself uses for signals; zero ceremony, fans out to multiple175 subscribers in-process, no server, trivially testable176- Escalate to: NATS (Apache-2.0) — genuinely different tool, not a bigger version of this one: needs177 a separately-provisioned `nats-server` process, and no official/maintained Python pytest-fixture178 package exists for local testing (the two community attempts are both abandoned), so it doesn't179 qualify as a local default here even though it's a strong product once you're actually running a180 server181182## Graph data183184- Snippet: [`references/snippets/graph-data.py`](references/snippets/graph-data.py)185- Default: `ladybug` (MIT, `pip install ladybug`) — embedded, Cypher query language, no server186- Why: this is the actively-maintained continuation of Kuzu, forked by the community 3 days after187 Kuzu's Oct 2025 archival (Apple acquisition) — same underlying engine, new stewardship, not a188 rewrite. Daily commits, MIT license, named active maintainers. Still under a year old as a project189 name, so treat as promising-but-young rather than as battle-tested as, say, SQLite190- Lightweight alternative: for relationship modeling that doesn't need real graph-traversal191 performance or a query language, a plain `edges(src, dst, relation)` table in your relational192 store is still simpler — reach for `ladybug` specifically when you need actual graph algorithms/193 traversal at more than toy scale. Pair either with `networkx` (BSD-3-Clause) for in-memory graph194 algorithms over already-loaded data195- Escalate to: Neo4j Aura, Amazon Neptune196197## Blob / object storage198199- Snippet: [`references/snippets/blob-storage.py`](references/snippets/blob-storage.py)200- Default: plain `pathlib.Path` file writes — no library201- Why: zero dependency, trivial `tmp_path` testing; adding an abstraction for cloud storage you202 don't have a concrete plan for yet is exactly the case YAGNI is for203- Escalate to: `fsspec` (BSD-3-Clause) + `s3fs`/`gcsfs` once cloud storage is an actual plan, not a204 maybe — same `fs.open()`/`fs.ls()` calls, only the protocol string changes205206## Time-series data207208- Snippet: [`references/snippets/time-series.py`](references/snippets/time-series.py)209- Default: `duckdb` (MIT) — same tool as the Analytical/OLAP category above210- Why: DuckDB's window functions, `time_bucket`, gap-filling, and ASOF joins are genuinely strong211 for time-series analysis and meaningfully cut boilerplate versus hand-rolling the same queries in212 raw SQL — the better tool for this concern specifically, independent of whether a project also213 needs it for OLAP work214- Lightweight alternative: a plain SQLite table with a timestamp-indexed column, if the need is215 truly just "store timestamped rows and filter by range," with no resampling/windowing216- Escalate to: InfluxDB, TimescaleDB217218## Editing this skill219220This file is _copied_ into `~/.agents/skills/db-defaults` at install time, never symlinked, so221**editing the deployed copy is local drift** — the exact thing this skill exists to prevent, and it222reaches no other machine. Edit the source in the repo this was installed from, push, and re-run223`skills add <that source> --global --skill db-defaults` to refresh every project's copy. If you224installed it from someone else's repo rather than your own fork, the source is theirs: open an issue225or a pull request there instead.226227## Starter snippets228229`references/snippets/` has one real, ruff-clean, self-contained Python file per category above —230each a verified `pip install` command in its docstring plus a working `test_*` function showing the231pytest-local pattern, directly copy-pasteable rather than a tutorial to adapt. Each category's232"Snippet:" line above links straight to its own file, so there's no need to open the others. This is233also where two real naming traps caught while writing them are documented at the point they matter:234`full-text-search.py` (the `tantivy` vs `tantivy-py` PyPI mixup) and `cron-scheduler.py` (the235nonexistent `SQLiteJobStore` class name).236237## Full rationale238239See [`references/rationale.md`](references/rationale.md) — the GitHub/PyPI evidence, the options240that were considered and rejected per category, and the reasoning behind every branch/alternative241above.