<?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: Seva D</title>
    <description>The latest articles on DEV Community by Seva D (@vsdudakov).</description>
    <link>https://dev.to/vsdudakov</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4008771%2F0ca9df75-f63e-4a2c-b57d-5548f7ffd1a1.jpg</url>
      <title>DEV Community: Seva D</title>
      <link>https://dev.to/vsdudakov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vsdudakov"/>
    <language>en</language>
    <item>
      <title>I built a Python ORM with a Rust engine — here's how the GIL, PyO3, and asyncio actually cooperate</title>
      <dc:creator>Seva D</dc:creator>
      <pubDate>Mon, 29 Jun 2026 22:16:56 +0000</pubDate>
      <link>https://dev.to/vsdudakov/i-built-a-python-orm-with-a-rust-engine-heres-how-the-gil-pyo3-and-asyncio-actually-cooperate-4fkj</link>
      <guid>https://dev.to/vsdudakov/i-built-a-python-orm-with-a-rust-engine-heres-how-the-gil-pyo3-and-asyncio-actually-cooperate-4fkj</guid>
      <description>&lt;p&gt;I like Tortoise ORM. Django-style models, async-first, clean. But I wanted more speed on read-heavy paths without reaching for SQLAlchemy, so I built &lt;a href="https://github.com/vsdudakov/yara-orm" rel="noopener noreferrer"&gt;&lt;strong&gt;yara-orm&lt;/strong&gt;&lt;/a&gt;: a Tortoise-style async ORM where the model and query layer is Python, but the engine — connection pooling, parameter binding, and row decoding — is written in Rust (PyO3 over &lt;code&gt;tokio-postgres&lt;/code&gt; and &lt;code&gt;rusqlite&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The API is exactly what you'd expect:&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;yara_orm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;YaraOrm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in_transaction&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Model&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;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IntField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Model&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;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IntField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKeyField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;related_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;books&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;YaraOrm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres://localhost/app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# or sqlite://./app.db
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;YaraOrm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_schemas&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;ada&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;Author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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;Ada&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;hot&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;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;author__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;Ada&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;in_transaction&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Atomic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ada&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the API isn't the interesting part — Tortoise already nailed that. The interesting part is underneath: a Rust database engine and Python's asyncio loop have to share one interpreter, and if you get it wrong the GIL collapses the whole thing back into a single-threaded program. Here's exactly how that works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two runtimes in one process
&lt;/h2&gt;

&lt;p&gt;There are two schedulers running at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CPython's asyncio event loop&lt;/strong&gt; — single-threaded, on your main thread, where your &lt;code&gt;async def&lt;/code&gt; code runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tokio's multi-threaded runtime&lt;/strong&gt; — background worker threads that actually open sockets, send queries, and parse wire protocols.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The job is to let them cooperate so that the event loop never blocks on I/O, and the database I/O never blocks on the GIL. The GIL is the thing that makes that non-trivial.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the GIL shows up in Rust
&lt;/h2&gt;

&lt;p&gt;In PyO3 you can't touch a Python object without &lt;em&gt;proof&lt;/em&gt; that you hold the GIL. That proof is a token — &lt;code&gt;Python&amp;lt;'py&amp;gt;&lt;/code&gt; — threaded through the API: every function that reads or creates Python objects takes one, and the &lt;code&gt;'py&lt;/code&gt; lifetime ties every borrowed &lt;code&gt;Bound&amp;lt;'py, PyAny&amp;gt;&lt;/code&gt; to it. It's a compile-time guarantee. No token, no access to the interpreter.&lt;/p&gt;

&lt;p&gt;So the GIL boundary is explicit in the code, and only two places actually need it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Binding parameters&lt;/strong&gt; (Python → Rust): pulling a Python &lt;code&gt;int&lt;/code&gt; / &lt;code&gt;str&lt;/code&gt; / &lt;code&gt;datetime&lt;/code&gt; / &lt;code&gt;UUID&lt;/code&gt; out and converting it to a Rust value &lt;em&gt;reads&lt;/em&gt; Python objects, so it holds the GIL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decoding rows&lt;/strong&gt; (Rust → Python): constructing the &lt;code&gt;int&lt;/code&gt; / &lt;code&gt;str&lt;/code&gt; / &lt;code&gt;datetime&lt;/code&gt; you get back &lt;em&gt;creates&lt;/em&gt; Python objects, so it holds the GIL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything &lt;em&gt;between&lt;/em&gt; those two — acquiring a pooled connection, sending the query, waiting on the socket, parsing the wire protocol — touches no Python objects at all. So it runs with the &lt;strong&gt;GIL released&lt;/strong&gt;. That's the entire point: while Postgres is doing work and bytes are in flight, the GIL is free and other Python tasks run.&lt;/p&gt;

&lt;p&gt;To make that safe, the data crossing into the async world has to be &lt;strong&gt;owned and &lt;code&gt;Send&lt;/code&gt;&lt;/strong&gt;. yara-orm converts each parameter into a small Rust &lt;code&gt;Value&lt;/code&gt; enum &lt;em&gt;under the GIL&lt;/em&gt;, then hands an owned &lt;code&gt;Vec&amp;lt;Value&amp;gt;&lt;/code&gt; to the database layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Clone)]&lt;/span&gt;
&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;Uuid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Uuid&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the time real I/O starts there isn't a single &lt;code&gt;Py&amp;lt;...&amp;gt;&lt;/code&gt; or &lt;code&gt;Bound&amp;lt;...&amp;gt;&lt;/code&gt; in scope — nothing borrowed from the interpreter, nothing that needs the GIL — so Tokio is free to move the future across worker threads. This is also why you &lt;em&gt;can't&lt;/em&gt; just hold a &lt;code&gt;PyObject&lt;/code&gt; across an &lt;code&gt;.await&lt;/code&gt;: a GIL-bound handle isn't &lt;code&gt;Send&lt;/code&gt;, and the borrow checker stops you. The architecture is partly &lt;strong&gt;forced&lt;/strong&gt; by PyO3's types, which is a feature, not a limitation.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a Rust future becomes a Python &lt;code&gt;await&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The model layer calls &lt;code&gt;await engine.fetch_rows(sql, params)&lt;/code&gt;. On the Rust side &lt;code&gt;fetch_rows&lt;/code&gt; doesn't block — it returns a Python awaitable, built with &lt;code&gt;pyo3-async-runtimes&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;fetch_rows&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'p&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Python&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'p&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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="nb"&gt;String&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="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PyResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Bound&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PyAny&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.backend&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;future_into_py&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// runs on a Tokio worker thread, GIL released&lt;/span&gt;
        &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="nf"&gt;.fetch_all_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_pyerr&lt;/span&gt;&lt;span class="p"&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;code&gt;future_into_py&lt;/code&gt; does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Creates a Python &lt;code&gt;asyncio.Future&lt;/code&gt; bound to the &lt;strong&gt;currently running event loop&lt;/strong&gt; — which is why this has to be called from inside a running loop.&lt;/li&gt;
&lt;li&gt;Spawns the Rust &lt;code&gt;async move { ... }&lt;/code&gt; onto the &lt;strong&gt;Tokio runtime&lt;/strong&gt;, which lives on its own background threads, completely separate from the asyncio loop thread.&lt;/li&gt;
&lt;li&gt;When the Rust future finishes — on a Tokio worker thread — it schedules the result back onto the asyncio loop with &lt;code&gt;loop.call_soon_threadsafe(...)&lt;/code&gt;, the &lt;em&gt;only&lt;/em&gt; thread-safe way to poke the loop from another thread.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From Python it's an ordinary &lt;code&gt;await&lt;/code&gt;: the coroutine suspends, the event loop keeps serving other tasks, and when the Tokio side resolves the future the loop wakes the coroutine with the rows. The decode step (Rust &lt;code&gt;Value&lt;/code&gt; → Python objects) re-acquires the GIL for the few microseconds it takes to build the result, then releases it again.&lt;/p&gt;

&lt;p&gt;So the two runtimes never block each other: &lt;strong&gt;the asyncio thread is never blocked on I/O, and the database I/O never holds the GIL.&lt;/strong&gt; The GIL is held only during the cheap conversion at each end.&lt;/p&gt;

&lt;p&gt;That last sentence is the whole performance story, and it tells you exactly where to optimize. The query builder runs &lt;strong&gt;once&lt;/strong&gt; per query; the decoder runs &lt;strong&gt;once per row&lt;/strong&gt;. A &lt;code&gt;SELECT&lt;/code&gt; returning 5,000 rows runs your row-hydration code 5,000 times — that loop is where the time goes, on &lt;em&gt;every&lt;/em&gt; ORM. So that's where the effort went:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;uuid.UUID&lt;/code&gt; and &lt;code&gt;decimal.Decimal&lt;/code&gt; type objects are imported &lt;strong&gt;once per interpreter&lt;/strong&gt;, not re-resolved per cell (UUID primary keys show up on basically every query).&lt;/li&gt;
&lt;li&gt;Postgres decoding dispatches on the column's type OID via a jump table, instead of walking a 16-deep chain of type comparisons per cell.&lt;/li&gt;
&lt;li&gt;SQLite upper-cases each column's declared type &lt;strong&gt;once per result set&lt;/strong&gt; instead of per cell, and binds parameters by &lt;em&gt;move&lt;/em&gt; rather than copying them twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are glamorous. All of them compound across rows — and crucially, all of them are &lt;em&gt;inside&lt;/em&gt; the short GIL-held window, where every microsecond is one the event loop can't use.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on blocking drivers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;rusqlite&lt;/code&gt; is synchronous — a blocking C library. Call it directly on a Tokio worker and you stall an async executor thread. So SQLite work runs on a dedicated blocking thread pool (the connection pool's &lt;code&gt;interact&lt;/code&gt; / &lt;code&gt;spawn_blocking&lt;/code&gt;): same decoupling principle, one layer down. Blocking work stays off &lt;em&gt;both&lt;/em&gt; the asyncio loop and Tokio's async workers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest tradeoff
&lt;/h2&gt;

&lt;p&gt;There's another way to build this: serialize the query to bytes (MessagePack or similar) in Python, hand the bytes to Rust, get bytes back, and never touch a Python object in Rust at all — so the GIL is never involved. yara-orm goes the direct-PyO3 route instead.&lt;/p&gt;

&lt;p&gt;Holding the GIL during conversion is a real cost, and free-threaded Python (3.13+ &lt;code&gt;--disable-gil&lt;/code&gt;) changes the calculus: if your workload is many OS threads decoding result sets in parallel, the GIL-free, bytes-on-the-wire design can pull ahead. For the typical single-event-loop async service, paying the GIL for a few microseconds of conversion and skipping a serialize/deserialize round trip is the better trade.&lt;/p&gt;

&lt;p&gt;There's no free lunch — just a choice, and most "we rewrote it in Rust" posts don't tell you which one they made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&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;yara-orm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/vsdudakov/yara-orm" rel="noopener noreferrer"&gt;https://github.com/vsdudakov/yara-orm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://vsdudakov.github.io/yara-orm/" rel="noopener noreferrer"&gt;https://vsdudakov.github.io/yara-orm/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've built a PyO3 bridge yourself: did you go IR-over-MessagePack or direct conversion — and would you make the same call again? Genuinely curious how others have drawn the GIL boundary.&lt;/p&gt;

</description>
      <category>python</category>
      <category>rust</category>
      <category>database</category>
      <category>fastapi</category>
    </item>
  </channel>
</rss>
