Minimal async DB layer for Python. Typed CRUD over Pydantic, raw SQL when you need it
Project description
etchdb
Minimal async DB layer for Python. Typed CRUD over Pydantic. Raw SQL when you need it.
Status
Alpha. v0.2.0 on PyPI. Built in public from day one; expect tightening between alpha releases.
Example
from etchdb import DB, Row
class User(Row):
__table__ = "users"
id: int | None = None # leave unset and the DB allocates it (SERIAL / INTEGER PK)
name: str
email: str | None = None
# Connect (driver inferred from URL scheme)
db = await DB.from_url("postgresql+asyncpg://user@host/db")
# Typed CRUD
alice = await db.insert(User(name="Alice")) # alice.id is now populated by the DB
user = await db.get(User, id=alice.id) # one row or None
users = await db.query(User) # list of rows
await db.update(User(id=alice.id, name="Alice B")) # partial: email is preserved
await db.delete(alice)
# Add `where=` to AND extra equality filters onto the PK. Atomic, so
# the scope check runs in the same statement as the update.
await db.update(User(id=alice.id, name="Alice B"),
where={"email": "alice@example.com"})
# Partial update without making your model lie about the schema:
# patch() builds a Row with only the given fields set; no validation,
# so models with required NOT NULL columns still flow through.
await db.update(User.patch(id=alice.id, name="Alice B"))
# Typed-result raw SQL (covers most joins)
users = await db.fetch_models(User, """
SELECT u.* FROM users u JOIN orders o ON o.user_id = u.id
WHERE o.created_at > $1
""", since)
# Untyped raw SQL (mirrors asyncpg)
rows = await db.fetch("SELECT count(*) FROM events WHERE site_id = $1", site_id)
val = await db.fetchval("SELECT count(*) FROM users") # always returns the count;
# for non-aggregate selects,
# fetchval returns None on no row.
await db.execute("UPDATE users SET active = false WHERE id = $1", uid)
# Transactions
async with db.transaction() as tx:
await tx.insert(User(name="Carol"))
await tx.execute("INSERT INTO audit_log (...) VALUES (...)")
# Inspect SQL before executing (etchdb's defining feature)
q = db.compose("get", User, id=1)
print(q.sql) # SELECT id, name, email FROM users WHERE id = $1
print(q.params) # [1]
# Same inspector without a live DB - useful in tests:
from etchdb import sql
q = sql.compose("get", User, id=1, placeholder=lambda i: f"${i + 1}")
insert only emits the columns you actually set, so an unset id lets the database allocate one (SERIAL or INTEGER PRIMARY KEY); the returned Row reflects the DB's view (RETURNING *), so server-defaults like id and created_at are populated in place. update does the same: a column you didn't touch keeps its current value rather than being clobbered. An explicit None counts as set in both cases.
update and delete use the value of every column in __pk__ (default: ("id",)) as the WHERE clause; override the class attribute for a composite or differently-named primary key. Add extra equality filters with where={...} for guarded updates: multi-tenant scoping like where={"user_id": current_user} is the canonical use case. Raw SQL is still the right tool when you need anything richer than equality.
Use Row.patch(**fields) to build a partial Row that satisfies neither validation nor missing-required-field checks. It's the right shape when you want partial updates against a model with NOT NULL columns.
Install
Drivers are optional extras. Install only what you use:
pip install etchdb[asyncpg] # asyncpg + Postgres
pip install etchdb[psycopg] # psycopg3 + Postgres
pip install etchdb[sqlite] # aiosqlite + SQLite
pip install etchdb[all] # everything
The top-level etchdb namespace depends only on Pydantic. Driver subpackages import their driver eagerly with a clear error if it is not installed.
from etchdb import DB, Row # always safe
from etchdb.asyncpg import AsyncpgAdapter # requires asyncpg
# Bring your own pool
db = DB(AsyncpgAdapter.from_pool(my_pool))
from_url keeps the construction surface tiny. For pool-init concerns
(pgvector tuning, JSONB / ENUM codec registration, custom min_size /
max_size), construct the pool yourself and pass it via from_pool.
Example, registering a Postgres ENUM as a Python str via asyncpg's
set_type_codec:
import asyncpg
from etchdb import DB
from etchdb.asyncpg import AsyncpgAdapter
async def init_conn(conn):
await conn.set_type_codec(
"memory_domain", # the ENUM type name
encoder=str, decoder=str,
schema="public",
format="text",
)
pool = await asyncpg.create_pool(url, init=init_conn, min_size=2, max_size=10)
db = DB(AsyncpgAdapter.from_pool(pool))
Both Postgres adapters take libpq-native $1, $2, ... placeholders in raw SQL. The psycopg adapter uses AsyncRawCursor so the $N form works there too; psycopg's default %s form is not used and will produce a Postgres syntax error.
Why
Most Python ORMs are heavy, opinionated, and leak at the seams when you reach for pgvector or PostGIS. Raw asyncpg works, but every project ends up writing the same Pydantic-bridge code. etchdb closes that gap without becoming a framework.
The design also targets AI-assisted development: predictable verbs, no metaclass magic, no implicit context vars, no lazy loading, every typed operation produces inspectable SQL. Code an LLM can write correctly on the first attempt.
Goals
- Driver-agnostic (asyncpg or psycopg3, swap freely)
- Multi-dialect (Postgres primary, SQLite secondary, MySQL maybe)
- Async native, no sync wrappers
- Typed CRUD via Pydantic; raw SQL as first-class escape valve
- Inspectable SQL: every typed op exposes its
(sql, params)without executing
Non-goals
- Query builder beyond simple CRUD (use raw SQL for joins)
- Implicit relationships, lazy loading, eager loading
- Sync support
- A second canonical way to do anything
Migrations
Out of scope for v0.1. A small forward-only, file-based migration helper (no autogenerate, no rollback, no DAG) is planned for a later release. etchdb owns no schema state today, so any external tool slots in fine in the meantime: Alembic if you also use SQLAlchemy, dbmate or sqitch if you don't, or a few db.execute calls in your bootstrap path.
Built with AI assistance
Built with Claude Code as the primary development assistant. Design, code, and commits are reviewed and shipped by Hannu Varjoranta. Building in public, openly using AI tooling, is part of the project's premise.
License
MIT.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file etchdb-0.2.0.tar.gz.
File metadata
- Download URL: etchdb-0.2.0.tar.gz
- Upload date:
- Size: 22.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8ef0ee4bcccd91f72735d2b1a076fd1c568d3a5e51d0a5eaf588f65eb74ba194
|
|
| MD5 |
30aa7f5eb4a5ad055614d869b50c29a7
|
|
| BLAKE2b-256 |
ab62a521a33826b489ac41264d21c385462827694ad5cf827039233ad9b1b32e
|
File details
Details for the file etchdb-0.2.0-py3-none-any.whl.
File metadata
- Download URL: etchdb-0.2.0-py3-none-any.whl
- Upload date:
- Size: 19.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f74ab76ecb4f5f141322246cd9db8e2225d6b3bec8787b89e82e0363a0906138
|
|
| MD5 |
df27136ee5a37409adf51b2f38fdd3ec
|
|
| BLAKE2b-256 |
cfffbca90ce6f23c28bc72d5081c505c6bb751ca035b1672e5f7f0607feaf51b
|