<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hannu Varjoranta</title>
    <description>The latest articles on DEV Community by Hannu Varjoranta (@varjoranta).</description>
    <link>https://dev.to/varjoranta</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3926469%2F723ca02d-7712-49fc-a1dc-ef302b7c0678.jpeg</url>
      <title>DEV Community: Hannu Varjoranta</title>
      <link>https://dev.to/varjoranta</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/varjoranta"/>
    <language>en</language>
    <item>
      <title>I keep writing the same Pydantic-asyncpg wrapper. So I published it.</title>
      <dc:creator>Hannu Varjoranta</dc:creator>
      <pubDate>Tue, 12 May 2026 07:11:33 +0000</pubDate>
      <link>https://dev.to/varjoranta/i-keep-writing-the-same-pydantic-asyncpg-wrapper-so-i-published-it-4cod</link>
      <guid>https://dev.to/varjoranta/i-keep-writing-the-same-pydantic-asyncpg-wrapper-so-i-published-it-4cod</guid>
      <description>&lt;p&gt;I've built this thin Pydantic-over-asyncpg layer too many times now.&lt;/p&gt;

&lt;p&gt;Every project ends up with the same shape: a &lt;code&gt;Row&lt;/code&gt; class for typed tables, raw SQL for the joins that don't fit into typed CRUD, transactions, no ORM magic. The fourth time I caught myself starting over, I published it. It's called &lt;code&gt;etchdb&lt;/code&gt;. Alpha on PyPI, v0.5 as of this post.&lt;/p&gt;

&lt;p&gt;This isn't a "we built our own ORM" story. It's the opposite -- it's about the smallest layer that closes the gap between raw &lt;code&gt;asyncpg&lt;/code&gt; and the Pydantic models you already have, with raw SQL as a first-class escape valve when CRUD isn't enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with the existing options
&lt;/h2&gt;

&lt;p&gt;I've shipped production code with most of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQLAlchemy&lt;/strong&gt; is the default answer for a reason. It's also heavy, opinionated, and leaks at the seams the moment you reach for &lt;code&gt;pgvector&lt;/code&gt; or PostGIS or any Postgres extension that ORMs don't know about. Async support is now native in 2.0, but the session lifecycle is still a thing you have to think about, and the typed model layer doesn't quite line up with how Pydantic feels in the rest of the codebase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tortoise&lt;/strong&gt;, &lt;strong&gt;Piccolo&lt;/strong&gt;, &lt;strong&gt;SQLModel&lt;/strong&gt; -- better stories, all of them. SQLModel especially is close to what I want. But they all bring their own model class layered on top of (or instead of) Pydantic, and they all want to own more of the query surface than I want to give up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Raw &lt;code&gt;asyncpg&lt;/code&gt;&lt;/strong&gt; is what I keep coming back to. It's fast, the API mirrors how I think about SQL, and it doesn't try to be clever. The only problem: every project ends up writing the same 80-line wrapper to bridge query results into Pydantic models. And that wrapper grows: insert with &lt;code&gt;RETURNING *&lt;/code&gt;, partial updates that don't clobber unset columns, transactional helpers, eventually some kind of typed &lt;code&gt;get(Model, id=...)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After watching myself write that same code four times, I gave it a name and pushed it to PyPI.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;etchdb&lt;/code&gt; looks like
&lt;/h2&gt;

&lt;p&gt;Two user-facing types: &lt;code&gt;DB&lt;/code&gt; and &lt;code&gt;Row&lt;/code&gt;. That's it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;etchdb&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Row&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__table__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;    &lt;span class="c1"&gt;# leave unset and the DB allocates it
&lt;/span&gt;    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql+asyncpg://user@host/db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;alice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;    &lt;span class="c1"&gt;# alice.id is now populated by the DB
&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# one row or None
&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# list of rows
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Alice B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# partial: email is preserved
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;insert&lt;/code&gt; only emits the columns you actually set, so an unset &lt;code&gt;id&lt;/code&gt; lets the database allocate one (SERIAL or &lt;code&gt;INTEGER PRIMARY KEY&lt;/code&gt;); the returned &lt;code&gt;Row&lt;/code&gt; reflects the DB's view via &lt;code&gt;RETURNING *&lt;/code&gt;, so server-defaults like &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;created_at&lt;/code&gt; are populated in place. &lt;code&gt;update&lt;/code&gt; does the same partial-emit thing: a column you didn't touch keeps its current value rather than being clobbered.&lt;/p&gt;

&lt;p&gt;That's most of what most of my CRUD looks like, day to day. When it's not enough, raw SQL is right there:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Typed-result raw SQL (covers most joins)
&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_models&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    SELECT u.* FROM users u JOIN orders o ON o.user_id = u.id
    WHERE o.created_at &amp;gt; $1
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Untyped raw SQL (mirrors asyncpg's vocabulary)
&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT count(*) FROM events WHERE site_id = $1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;site_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT count(*) FROM users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE users SET active = false WHERE id = $1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;db.execute&lt;/code&gt; returns the affected-row count as &lt;code&gt;int&lt;/code&gt;, normalised across asyncpg, psycopg, and aiosqlite. That used to be the one thing that bit me every time I swapped drivers -- asyncpg returns &lt;code&gt;"UPDATE 5"&lt;/code&gt; strings, psycopg returns a cursor, aiosqlite discards it entirely.&lt;/p&gt;
&lt;h2&gt;
  
  
  The features that grew out of adoption
&lt;/h2&gt;

&lt;p&gt;I won't list everything. A few that came from actually using it against real code:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Atomic multi-tenant scoping&lt;/strong&gt; via &lt;code&gt;where=&lt;/code&gt; on update / delete:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Alice B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;current_user_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;# AND'd onto the PK in one statement
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The check and the update run in the same SQL statement, so there's no TOCTOU window between "is this user allowed to edit?" and the write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;IS NULL&lt;/code&gt; filters that actually find rows&lt;/strong&gt;. This is a correctness fix that hit me on the first real project -- &lt;code&gt;db.get(User, deleted_at=None)&lt;/code&gt; used to silently match zero rows because &lt;code&gt;field = NULL&lt;/code&gt; is never true in SQL. Now it emits &lt;code&gt;WHERE deleted_at IS NULL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;IN (...)&lt;/code&gt; via list values&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;recent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;    &lt;span class="c1"&gt;# WHERE id IN ($1, $2, $3)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Atomic increments and DB-side timestamps&lt;/strong&gt; via sentinels:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;etchdb&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Inc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Now&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Inc&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;            &lt;span class="c1"&gt;# n = n + 1, atomic
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;   &lt;span class="c1"&gt;# CURRENT_TIMESTAMP
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;&lt;code&gt;on_conflict="upsert"&lt;/code&gt;&lt;/strong&gt; for create-or-update:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a@x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;on_conflict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upsert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Typed exceptions across drivers&lt;/strong&gt;, so the same &lt;code&gt;except IntegrityError&lt;/code&gt; works against asyncpg, psycopg, and aiosqlite. The original driver exception is preserved as &lt;code&gt;__cause__&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inspectable SQL&lt;/strong&gt; -- every typed op exposes its &lt;code&gt;(sql, params)&lt;/code&gt; without executing:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# SELECT id, name, email FROM users WHERE id = $1
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# [1]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;I lean on this constantly when debugging or when an agent is generating code against the library -- being able to print what etchdb is about to send to the database closes a lot of "why isn't this working" loops.&lt;/p&gt;
&lt;h2&gt;
  
  
  Migrations: forward-only, file-based, paranoid
&lt;/h2&gt;

&lt;p&gt;v0.5's headline feature. I needed a migration story for the projects I was building with etchdb, and Alembic is the wrong size for a library that does forward-only schema changes.&lt;/p&gt;

&lt;p&gt;Drop &lt;code&gt;.sql&lt;/code&gt; files into a directory; filenames sort to apply order:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;migrations/
  0001_create_users.sql
  0002_add_email_index.sql
  0003_add_articles.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- migrations/0001_create_users.sql&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;users_email_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;applied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;migrate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;migrations/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;migration_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;migrations/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Each migration runs in its own implicit transaction on Postgres. Don't write &lt;code&gt;BEGIN&lt;/code&gt; / &lt;code&gt;COMMIT&lt;/code&gt; / &lt;code&gt;ROLLBACK&lt;/code&gt; in the file -- the runner owns transaction control and rejects files that try to take it back. For DDL that won't run inside a transaction (&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;), put &lt;code&gt;-- etchdb:no-transaction&lt;/code&gt; on the first non-blank line.&lt;/p&gt;

&lt;p&gt;The thing I cared most about getting right: &lt;strong&gt;strict consistency&lt;/strong&gt;. Tracking lives in a &lt;code&gt;_etchdb_migrations&lt;/code&gt; table with &lt;code&gt;(filename, checksum, applied_at)&lt;/code&gt;. The runner refuses to operate when state is inconsistent: an applied file's content has changed (drift), or an applied filename is no longer in the directory (disappearance). Both fail loudly with the recovery command in the error message. Silent continuation with unknown state is exactly what a forward-only tool exists to prevent.&lt;/p&gt;

&lt;p&gt;If you need autogenerate, branching, or rollback, Alembic / dbmate / sqitch still slot in fine -- etchdb's helper covers the simple forward-only case without dragging those in.&lt;/p&gt;
&lt;h2&gt;
  
  
  Designed for AI-assisted development
&lt;/h2&gt;

&lt;p&gt;This is the part I think matters most for where Python is right now.&lt;/p&gt;

&lt;p&gt;Predictable verbs (&lt;code&gt;db.get&lt;/code&gt;, &lt;code&gt;db.query&lt;/code&gt;, &lt;code&gt;db.insert&lt;/code&gt;, &lt;code&gt;db.update&lt;/code&gt;, &lt;code&gt;db.delete&lt;/code&gt;, &lt;code&gt;db.migrate&lt;/code&gt;). No metaclass magic. No implicit context vars. No lazy loading. Every typed operation produces inspectable SQL. The vocabulary mirrors asyncpg's, which is already in every model's training data.&lt;/p&gt;

&lt;p&gt;The result is that an LLM writing application code against etchdb gets it right on the first attempt much more often than against the big ORMs, because there's nothing surprising to learn. The bug class I hit constantly with SQLAlchemy (the model thinks one thing, the session thinks another, the cursor returned something else) just doesn't exist when the layer is this thin.&lt;/p&gt;

&lt;p&gt;I've been building etchdb the same way -- Claude Code as the primary development assistant, Codex for review between releases, occasional Gemini for a third opinion. Five versions on PyPI in five days, mostly driven by what fell out when I adopted v0.1 against an existing project and watched where the friction was. Each gap fed the next release.&lt;/p&gt;

&lt;p&gt;It's still alpha. Surface keeps tightening between versions. But the core (&lt;code&gt;DB&lt;/code&gt; + &lt;code&gt;Row&lt;/code&gt;) has stayed identical since v0.1, and I don't expect that to change.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where it lives
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/varjoranta" rel="noopener noreferrer"&gt;
        varjoranta
      &lt;/a&gt; / &lt;a href="https://github.com/varjoranta/etchdb" rel="noopener noreferrer"&gt;
        etchdb
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Minimal async DB layer for Python. Typed CRUD over Pydantic. Raw SQL when you need it.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;etchdb&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Minimal async DB layer for Python. Typed CRUD over Pydantic. Raw SQL when you need it.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Status&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;Alpha. v0.5.0 on PyPI. Built in public from day one; expect tightening between alpha releases.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Example&lt;/h2&gt;
&lt;/div&gt;

&lt;div class="highlight highlight-source-python notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;etchdb&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-c1"&gt;DB&lt;/span&gt;, &lt;span class="pl-v"&gt;Row&lt;/span&gt;
&lt;span class="pl-k"&gt;class&lt;/span&gt; &lt;span class="pl-v"&gt;User&lt;/span&gt;(&lt;span class="pl-v"&gt;Row&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;__table__&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;"users"&lt;/span&gt;
    &lt;span class="pl-s1"&gt;id&lt;/span&gt;: &lt;span class="pl-s1"&gt;int&lt;/span&gt; &lt;span class="pl-c1"&gt;|&lt;/span&gt; &lt;span class="pl-c1"&gt;None&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;None&lt;/span&gt;             &lt;span class="pl-c"&gt;# leave unset and the DB allocates it (SERIAL / INTEGER PK)&lt;/span&gt;
    &lt;span class="pl-s1"&gt;name&lt;/span&gt;: &lt;span class="pl-smi"&gt;str&lt;/span&gt;
    &lt;span class="pl-s1"&gt;email&lt;/span&gt;: &lt;span class="pl-s1"&gt;str&lt;/span&gt; &lt;span class="pl-c1"&gt;|&lt;/span&gt; &lt;span class="pl-c1"&gt;None&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;None&lt;/span&gt;

&lt;span class="pl-c"&gt;# Connect (driver inferred from URL scheme)&lt;/span&gt;
&lt;span class="pl-c"&gt;#   postgresql+asyncpg://...   asyncpg + Postgres&lt;/span&gt;
&lt;span class="pl-c"&gt;#   postgresql+psycopg://...   psycopg3 + Postgres&lt;/span&gt;
&lt;span class="pl-c"&gt;#   sqlite+aiosqlite:///...    aiosqlite + SQLite&lt;/span&gt;
&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-c1"&gt;DB&lt;/span&gt;.&lt;span class="pl-c1"&gt;from_url&lt;/span&gt;(&lt;span class="pl-s"&gt;"postgresql+asyncpg://user@host/db"&lt;/span&gt;)

&lt;span class="pl-c"&gt;# Typed CRUD&lt;/span&gt;
&lt;span class="pl-s1"&gt;alice&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;insert&lt;/span&gt;(&lt;span class="pl-en"&gt;User&lt;/span&gt;(&lt;span class="pl-s1"&gt;name&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"Alice"&lt;/span&gt;))           &lt;span class="pl-c"&gt;# alice.id is now populated by the DB&lt;/span&gt;
&lt;span class="pl-s1"&gt;user&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;get&lt;/span&gt;(&lt;span class="pl-v"&gt;User&lt;/span&gt;, &lt;span class="pl-s1"&gt;id&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;alice&lt;/span&gt;.&lt;span class="pl-c1"&gt;id&lt;/span&gt;)                &lt;span class="pl-c"&gt;# one row&lt;/span&gt;&lt;/pre&gt;…
&lt;/div&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/varjoranta/etchdb" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;etchdb[asyncpg]    &lt;span class="c"&gt;# asyncpg + Postgres&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;etchdb[psycopg]    &lt;span class="c"&gt;# psycopg3 + Postgres&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;etchdb[sqlite]     &lt;span class="c"&gt;# aiosqlite + SQLite&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;etchdb[all]        &lt;span class="c"&gt;# everything&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres is the primary target, with SQLite as a secondary backend (mostly for tests and embedded use). Drivers are optional extras so the top-level namespace depends only on Pydantic.&lt;/p&gt;

&lt;p&gt;If you've also been writing the same thin asyncpg+Pydantic glue layer in every project, give it a shot. Issues and feedback welcome on GitHub.&lt;/p&gt;

</description>
      <category>python</category>
      <category>database</category>
      <category>async</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
