<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="/feed.xml" rel="self" type="application/atom+xml" /><link href="/" rel="alternate" type="text/html" /><updated>2026-09-15T19:18:14+00:00</updated><id>/feed.xml</id><title type="html">Lukas Valatka</title><subtitle></subtitle><entry><title type="html">Battle-tested - the new north star for quality</title><link href="/2026/09/15/building-is-over.html" rel="alternate" type="text/html" title="Battle-tested - the new north star for quality" /><published>2026-09-15T00:00:00+00:00</published><updated>2026-09-15T00:00:00+00:00</updated><id>/2026/09/15/building-is-over</id><content type="html" xml:base="/2026/09/15/building-is-over.html"><![CDATA[<p>POCs mean nothing to me anymore.</p>

<!--more-->

<p>The other day, I was thinking about how I pick my stack.</p>

<p>“Built” means nothing to me anymore. Agents can produce sophisticated code — well-structured, true rocket science. They can build almost anything without breaking a sweat. Tests green, always.</p>

<p>I choose battle-tested implementations. I let time decide which implementations win and which perish. Those that survive must have gone through the brutal cycles of reality: functional issues, security issues, performance issues. Even the most tasteless software, when exposed to enough user feedback, eventually ends up in a slick state (or dies).</p>

<p>I choose mature technologies. Agents can implement anything. The value lies in technologies that actually solve real problems — that have found product-market fit. Only time can tell this.</p>

<p>The next time you want to add that 5-star dependency, how about you let your agent vibe-copy it instead?</p>]]></content><author><name></name></author><summary type="html"><![CDATA[POCs mean nothing to me anymore.]]></summary></entry><entry><title type="html">FOSDEM 2026. A weekend of tech talks and beer</title><link href="/2026/02/07/fosdem-26.html" rel="alternate" type="text/html" title="FOSDEM 2026. A weekend of tech talks and beer" /><published>2026-02-07T00:00:00+00:00</published><updated>2026-02-07T00:00:00+00:00</updated><id>/2026/02/07/fosdem-26</id><content type="html" xml:base="/2026/02/07/fosdem-26.html"><![CDATA[<p>I’m back at FOSDEM. Two days in a whirlwind of Rust, Arrow, lakehouses, type checkers and containers. Beer and fries with some old friends, too 🇧🇪. What more could you ask for?</p>

<!--more-->

<p>I am back at FOSDEM, in Brussels. Unlike last year, I flew in for the weekend. The bus was terribly late. And here’s the first situation I found myself in:</p>

<p><img src="/assets/1.png" alt="img" /></p>

<center><i>Folks patiently waiting to hear about the future of Git</i></center>
<p><br /></p>

<p>Having confirmed with one of the volunteers that the queue situation was a real thing indeed, I hurried back to the end of the line. <em>What if I don’t get in?</em> Ten to twenty minutes later, a flock of people got out of the stadium-like auditorium, and every one of us found ourselves a seat inside. Patience pays off!</p>

<h3 id="the-future-of-git-whats-next">The Future of Git: What’s Next?</h3>

<p><img src="/assets/2.png" alt="img" /></p>

<p>Now we are back! A Git maintainer / GitLab staff engineer revealed that developer experience is their key priority. Sourcing inspiration from <a href="https://github.com/jj-vcs/jj">Jujutsu</a> — a Git-compatible frontend.</p>

<p>Some features to watch for: we’ll be able to switch branches without stashing (essentially auto-commit). We’ll be able to edit commits. Child branches will automatically rebase on parent updates.</p>

<p><img src="/assets/4.png" alt="img" /></p>
<center><i>Auto-rebase of child branches could apply well to stacked branching</i></center>
<p><br /></p>

<p>Maintainers will on enabling stronger SHA-256 (instead of SHA-1) hash functions for file checksums, reducing the chance of someone sneaking in malicious code with the same hash but different contents.</p>

<p>Overall, an okay keynote, though it spent too much time on the details.</p>

<h3 id="lightning-talks---love-the-format">Lightning talks - love the format!</h3>

<p>Afterwards, I rushed to the databases hall, where the lightning talks had already started. Now, this was the FOSDEM I remembered — punchy five-minute talks, perfect for our TikTok fried brains.</p>
<h4 id="rondb-unified-ml-feature-stores">RonDB: Unified ML Feature Stores</h4>

<p><img src="/assets/3.png" alt="img" /></p>

<p>The first talk on the line was the RonDB session. RonDB promises to unify both offline and online stores (think classical BigQuery / ClickHouse + Redis) into a single database for ML feature building and serving. That’s a nice operational benefit. In my current place, though, we already benefit a lot from Feast, which achieves this unification as a software-level abstraction. Still, it sounds cool if you’re starting from scratch.</p>
<h4 id="duckdb-is-spark-dead-already">DuckDB: Is Spark dead already??</h4>

<p>Next-up on the line was DuckDB. I am always excited about DuckDB.</p>

<p><img src="/assets/5.png" alt="img" /></p>

<p>DuckDB has somewhat recently unveiled its Iceberg support. It’s realistically read-only, since they don’t support upserts (merge into), which is highly critical for idempotent writes. I <a href="https://github.com/duckdb/duckdb-iceberg/issues/665">gave it a shot once</a> , and it seems it has limitations for some catalog types as well. Despite these drawbacks, the progress is clear, and I bet in a few years or so Iceberg ecosystem will reach similar maturity as Parquet, with different mature implementations in various languages. Just a few more years, and you can drop Spark - for medium data use-cases though.</p>

<h4 id="grafana-labs-sql--analytics-for-operational-metrics">Grafana Labs: SQL + Analytics for operational metrics</h4>

<p><img src="/assets/6.png" alt="img" /></p>

<p>Lastly, I was surprised to learn that even Grafana Labs is spearheading a SQL + analytics stack like dbt as a valuable technology direction on top of operational use cases. That’s such an understatement, in my opinion — DWHs love appends, and operational metrics are all time-series appends; alerts and metrics are really just windows + group-bys on columns. Perfect efficiency! And when was the last time you actually enjoyed writing PromQL?</p>

<h3 id="lunch-break">Lunch Break</h3>

<p>Time for lunch. The typical FOSDEM menu is slightly overpriced, fuel-like food: burgers, fries, pizzas. But hey, can’t complain; the conference itself is free.</p>

<p><img src="/assets/7.png" alt="img" /></p>

<center><i>Spotted a self-balancing droid while waiting for a slice of pizza</i></center>
<p><br /></p>

<h3 id="containers-ml-models-as-containers">Containers, ML models as containers?</h3>

<p>Having fueled myself—and knocked back an espresso shot — I rushed back, this time to the containers track room. I wanted to see something new, outside of databases and Python.</p>

<p><img src="/assets/8.png" alt="img" /></p>

<p>Basically, <code class="language-plaintext highlighter-rouge">docker model run</code>. Did Docker eventually manage to create a perfect ML model packaging format? The model, its dependencies, I/O schemas—able to run in any container-like environment? Only for LLMs, though…</p>

<p>In essence, you can package your favorite LLMs supported by frameworks like VLLM into an OCI-compatible artifact, storing it on GCP’s Artifact Registry or your own hosted registry (argument: you don’t want private stuff on HuggingFace). Neat—but I was really hoping they had solved this for any arbitrary model, like scikit-learn with custom preprocessing (and maybe even beat MLflow).</p>

<h3 id="apache-arrow-the-future-of-data-exchange">Apache Arrow: The Future of Data Exchange</h3>

<p>Next on the line was the Apache Arrow talk:</p>

<p><img src="/assets/9.png" alt="img" /></p>

<p>I had been waiting for the Apache Arrow talk. Matt is an engaging presenter. Arrow remains a common in-memory format, allowing different systems to communicate without serializing or deserializing data.</p>

<p>An argument he focused on this year is that row-based communication, like JDBC, is wasteful. Since most sources have mostly converged to columnar memory layouts, transferring data in columnar Arrow format eliminates the need to transpose it. CPU is the bottleneck, not network speed anymore. Hence comes Columnar, building a faster JDBC with Arrow at its core.</p>

<p><img src="/assets/10.png" alt="img" /></p>

<p><a href="https://15721.courses.cs.cmu.edu/spring2018/papers/14-networking/p1022-muehleisen.pdf">Read up</a> on why exchanging data in columnar format is so beneficial in this rather historical paper (from 2017, haha) by one of the DuckDB co-authors.</p>

<h3 id="lakehouse-migration-for-streaming-lessons-learned">Lakehouse Migration for Streaming: Lessons Learned</h3>

<p>A cherry on top of the day was this talk on a lakehouse migration for a high-write streaming stack:</p>

<p><img src="/assets/11.png" alt="img" /></p>

<p>The talk served as an assurance for us, since we are exploring the lakehouse architecture at my current workplace.</p>

<p><img src="/assets/12.png" alt="img" /></p>

<p>They moved from a classic shared-nothing DB to shared-everything on object storage. In shared nothing approach, each node holding the full state to avoid read/write bottlenecks. This causes storage to balloon (multiply with each replica), also crashed replicas can’t serve since they need to be catching-up from WAL. By switching to stateless nodes with the Rust-based DataFusion query engine, they cut costs by 50% (no replica storage duplication) and improved speed (thanks to DataFusion itself mostly). An architecture usually reserved for DWHs, I was genuinely surprised they pulled this off for streaming.</p>

<p><img src="/assets/13.png" alt="img" /></p>

<center><i>Cathedral of Our Lady, Antwerp. An impressive sight at night!</i></center>
<p><br /></p>

<p>Eventually, unwinded after the day with some 9% Belgian beers.</p>

<h2 id="day-2-rust-and-python">Day 2: Rust and Python</h2>

<p>The previous day, we had discussed with some colleagues that the next day looked rather empty. Overall, it did turn out to be less packed than the first one, but nonetheless — punchy.</p>

<p>I started the day meeting an old colleague. The first few talks were a miss, thanks to the classic mistake of focusing too much on the details.</p>

<p>We eventually sneaked into the Rust track, packed full, for the talk by ClickHouse’s CTO himself!</p>

<h3 id="clickhouse-c-to-rust">Clickhouse: C++ to Rust</h3>

<p><img src="/assets/14.png" alt="img" /></p>

<p>Engaging delivery on ClickHouse’s journey from C++ to Rust. The CTO argued it only makes sense to swap C/C++ for Rust if:</p>

<ul>
  <li>There’s a Rust extension with a significant community around it, whereas C++ doesn’t have one. A good example is <code class="language-plaintext highlighter-rouge">delta-rs</code>, a Rust-based delta-log implementation ClickHouse adopted to support reading and writing from Delta Lakehouses.</li>
  <li>Rust is cool.  It serves as a marketing tool to attract maintainers.</li>
</ul>

<p>On the other hand, a full rewrite is a bad idea because:</p>
<ul>
  <li>Rust is too hyped—its underlying value might turned out to be often lower than it seems.</li>
  <li>Separate dependency management can be tricky. For example, ClickHouse had issues with both baked-in OpenSSL and the system’s dynamically linked OpenSSL, because the Rust dependency relied on a transitive version that differed from the C++ setup.</li>
</ul>

<p><img src="/assets/16.png" alt="img" /></p>

<h3 id="ty-the-fast-python-type-checker">ty: The Fast Python Type Checker</h3>

<p>Energized, we all jumped from Rust for databases into Rust for Python developer tooling. Meet Ty - the new fast Python type checker.</p>

<p><img src="/assets/17.png" alt="img" /></p>

<p>The main point — ty is fast not just because it’s written in Rust, but also because it leverages incremental computation. Whenever part of your source changes, it only recomputes the dependent parts for type checking. Ty’s been out of beta for a while—definitely give it a shot!</p>

<h3 id="wrap-up">Wrap-up</h3>

<p>We ended the day in one of the many FOSDEM indoor cafés, with some beers and fries. Met a few old colleagues, so we eventually skipped the rest of the presentations just to catch up…</p>

<p>Intense, knowledge-packed, social weekend. Hard to pin down a main theme — there was a bit of everything for everyone. That’s FOSDEM. See you in 2027!</p>]]></content><author><name></name></author><summary type="html"><![CDATA[I’m back at FOSDEM. Two days in a whirlwind of Rust, Arrow, lakehouses, type checkers and containers. Beer and fries with some old friends, too 🇧🇪. What more could you ask for?]]></summary></entry><entry><title type="html">A year with Uv, Python’s best package manager</title><link href="/2025/11/09/1-year-of-uv.html" rel="alternate" type="text/html" title="A year with Uv, Python’s best package manager" /><published>2025-11-09T00:00:00+00:00</published><updated>2025-11-09T00:00:00+00:00</updated><id>/2025/11/09/1-year-of-uv</id><content type="html" xml:base="/2025/11/09/1-year-of-uv.html"><![CDATA[<p>Uv was the best thing that had happened to Python. Used it for over one year now. Good time to reflect on what makes it stand out of the crowd.</p>

<!--more-->

<p>I’ve switched to uv for all of my Python projects—both legacy and new—over the past year. It offers many interesting features, but I’ve narrowed it down to three that make it an absolute joy to use.</p>

<h3 id="it-can-manage-python-versions">It can manage Python versions</h3>

<p>Not only UV can install dependencies, but can also install Python (-s). Great dev-ex, one command for both. An example - I constantly switch between 3 Python apps, all with different Python versions. With UV, I can go to project’s dir, run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv run pytest tests
</code></pre></div></div>

<p>and it <u>installs / switches to project's Python</u>. Infers straight from <code class="language-plaintext highlighter-rouge">pyproject.toml</code>:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[project]</span>
<span class="py">requires-python</span> <span class="p">=</span> <span class="py">"&gt;</span><span class="p">=</span><span class="mf">3.12</span><span class="p">,</span><span class="err">&lt;</span><span class="mf">3.14</span><span class="s">"</span><span class="err">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">uv run</code> will pull-in Python 3.13, at most. No need for a separate tool like pyenv, on top pyenv <a href="https://github.com/pyenv/pyenv/issues/1233">does not understand</a> <code class="language-plaintext highlighter-rouge">pyproject.toml</code>’s Python.</p>

<h3 id="it-is-fast">It is fast</h3>

<p>Faster than pip, or frankly any package manager. It feels <u>real-time</u>. Because of Rust™. Speed is rarely a tradeoff (from a user point of view).</p>

<h3 id="it-can-spin-up-virtual-envs-on-the-fly-the-script-mode">It can spin-up virtual envs on the fly (the script mode)</h3>

<p>A year ago I thought <a href="https://valatka.dev/2025/01/12/on-killer-uv-feature.html">this one was a killer feature</a>. Putting it last though, I barely used it a few times. Nonetheless, it’s cool. Here’s a situation I had - I was debugging some parallel http calls, it was convenient to just run the script with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uvx <span class="nt">--python</span> 3.13 <span class="nt">--with</span> pandas test_concurrent_calls.py
</code></pre></div></div>

<p>Pulled-in Py13 + Pandas. Slight difference from a year ago - change <code class="language-plaintext highlighter-rouge">uv -&gt; uvx</code>, to leave no venv trace. Neat, right?</p>

<h3 id="cons">Cons?</h3>
<p>I’ve yet to find any. Once I couldn’t install some Torch dependency because UV kept insisting incompatibility with my system. That’s useful though, but I knew what I was doing and got blocked. Conda packages? Unsupported, but didn’t need those, at least for production apps.</p>

<p>To another year - now, please improve (speed of) <a href="https://github.com/astral-sh/ty">type checking</a> 🐍.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Uv was the best thing that had happened to Python. Used it for over one year now. Good time to reflect on what makes it stand out of the crowd.]]></summary></entry><entry><title type="html">LLMs are the missing link to shipping side projects</title><link href="/2025/10/25/llms-shipping-side-projects.html" rel="alternate" type="text/html" title="LLMs are the missing link to shipping side projects" /><published>2025-10-25T00:00:00+00:00</published><updated>2025-10-25T00:00:00+00:00</updated><id>/2025/10/25/llms-shipping-side-projects</id><content type="html" xml:base="/2025/10/25/llms-shipping-side-projects.html"><![CDATA[<p>I’ve been procrastinating on shipping a small UI improvement to my blog, and an LLM helped it happen.</p>

<!--more-->

<p>Some time ago, I was toying around with an idea for a tech product. I was procrastinating on building a landing page. Pixel pushing, as I used to call it, is an excruciating process. So, I asked an LLM<sup>1</sup> to do it for me. “Surprise me,” I thought. In a few minutes of prompting, I was amazed. Here it was — an interactive website with callouts, a contact form, and more. But that joy vanished quickly. Looking deeper, it felt too generic. I tried multiple times, but nothing felt right. I didn’t really know what made a good landing page, so LLMs couldn’t help me much either. I parked the idea of an autonomous coding assistant then.</p>

<p>Later, I found myself procrastinating again. I’d been delaying an idea to give a breath of fresh air to my personal blog. I had borrowed a simple idea from <a href="https://karpathy.github.io/">Andrej Karpathy’s</a> blog: move posts into the landing page and the bio section into a separate about page. It meant some yawn-inducing HTML and CSS. Once more, I called on an LLM<sup>1</sup>. It nailed it. With some interim feedback and a quick code review, I had a good result in a few minutes instead of an hour.</p>

<div style="display: flex; gap: 10px; align-items: center; justify-content: center; margin: 20px 0;">
  <img src="/assets/image-old.png" alt="Old design" style="max-width: 45%; height: auto;" />
  <img src="/assets/image-new.png" alt="New design" style="max-width: 45%; height: auto;" />
</div>
<p style="text-align: center; font-style: italic; margin-top: -10px;">Sonnet adjusted the UI just as instructed. Sonnet also coded-up this HTML to illustrate the argument.</p>

<p>Makes me think. In the first situation, I had no <u>vision</u> of the end result — I didn’t know what makes a good landing page. I could barely give the agent useful feedback. It saved some typing time but produced no good results. In the second, I had a <u>clear end state</u> in mind, so a bit of guiding brought us to a satisfactory end result, in no time. Both tasks’ scope was slightly different though, but I believe the key difference was having a clear end state.</p>

<p>I can finally un-procrastinate my side projects! They offer no monetary incentive (unlike jobs) so the process of shipping is painful - considerate amount <u>well-scoped</u> work with obvious - so <u>boring</u> - implementation effort, that I would always put aside. No more. Exciting times ahead!</p>

<p><small><sup>1</sup> In both cases, I used Claude Sonnet 3.5 and 4, via GitHub’s Copilot VSCode extension. The latter experiment obviously benefited from a more powerful model, but I doubt it was a significant factor.</small></p>]]></content><author><name></name></author><summary type="html"><![CDATA[I’ve been procrastinating on shipping a small UI improvement to my blog, and an LLM helped it happen.]]></summary></entry><entry><title type="html">Python 3.14 will change the way you parallelise code</title><link href="/2025/10/11/on-python-3-14-parallelization.html" rel="alternate" type="text/html" title="Python 3.14 will change the way you parallelise code" /><published>2025-10-11T00:00:00+00:00</published><updated>2025-10-11T00:00:00+00:00</updated><id>/2025/10/11/on-python-3-14-parallelization</id><content type="html" xml:base="/2025/10/11/on-python-3-14-parallelization.html"><![CDATA[<p>Free-threading in Python is finally here. Here’s why it matters and how I plan to use it.</p>

<!--more-->

<p>Free threading (no-GIL) is the main feature of <a href="https://www.python.org/downloads/release/python-3140/">Python 3.14</a>, setting it apart from its predecessors. We can process multiple tasks at the same time (so, parallelise) in Python with pure threads. <a href="https://blog.miguelgrinberg.com/post/python-3-14-is-here-how-fast-is-it">Just see the benchmarks</a> and <a href="https://valatka.dev/2024/12/28/async-io-is-not-enough.html">here</a>.</p>

<p>Not everyone might share my enthusiasm - but here’s why it matters:</p>

<p><strong>“I can just use multiprocessing.”</strong> Yes, but it comes at a cost — slow inter-process communication. Every argument must be pickled for message passing; objects can be slow to pickle, and some aren’t picklable at all. You need to minimize sync points and be selective about the data exchanged between processes.</p>

<p><strong>“We already have C and Rust extensions.”</strong> Like C NumPy, obviously use one if you can. But would you ever write one yourself just to parallelise?</p>

<p><strong>“We have async I/O.”</strong> Useful only if you’re I/O bound i.e. tons of external calls - <a href="https://valatka.dev/2024/12/28/async-io-is-not-enough.html">I’ve written about that before</a>.</p>

<p><strong>“I can scale horizontally”</strong>. Sure — but if you care about minimizing single-request latency, horizontal scaling only increases throughput. The time to process an individual request remains the same as long as the machine itself is unchanged, we merely add additional machines to serve more requests.</p>

<p>So, in theory, we have absolutely <code class="language-plaintext highlighter-rouge">None</code> ergonomic means for parallelisation in Python. 1Starting with 3.14, no-GIL aims to change that.</p>

<p>Here are a few practical use cases where I’ll immediately start exploring no-GIL:</p>

<p><strong>Algorithm or ML model inference</strong>. You want fast, customer-facing predictions. Batch APIs are full of Python glue for feature fetching and pre/post-processing, but inputs are easy to parallelise. It’s all CPU-cycles. At my previous job, we used multiprocessing to cut recommendation endpoint latency by several times (cost = super selective about message passing) — wish we had no-GIL back then.</p>

<p><strong>Existing async I/O apps</strong>. I suspect many async I/O apps also do decent amount CPU work that can’t currently be parallelised due to the GIL. I’m already excited to benchmark existing implementations — with <a href="https://labs.quansight.org/blog/scaling-asyncio-on-free-threaded-python">parallel event loops on the way</a>, we might see latencies drop!</p>

<p>I don’t think <strong>data pipelines</strong> will benefit from no-GIL though. Most don’t have latency requirements (rather throughput) and we already offload bulk of CPU work to specialised SQL / DF engines as Polars, DuckDB, PySpark (you are not going to be rewriting them soon).</p>

<p>Hopefully, this convinces you to try free threading / no-GIL. I am excited myself. Especially with <code class="language-plaintext highlighter-rouge">uv</code>, it’s as a easy as:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv run <span class="nt">--python</span><span class="o">=</span>3.14t myscript.py
</code></pre></div></div>

<p>Happy 🧵-ing!</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Free-threading in Python is finally here. Here’s why it matters and how I plan to use it.]]></summary></entry><entry><title type="html">On good interfaces</title><link href="/2025/08/08/on-good-interfaces.html" rel="alternate" type="text/html" title="On good interfaces" /><published>2025-08-08T00:00:00+00:00</published><updated>2025-08-08T00:00:00+00:00</updated><id>/2025/08/08/on-good-interfaces</id><content type="html" xml:base="/2025/08/08/on-good-interfaces.html"><![CDATA[<p>Didn’t think a tech book on fundamentals could strike me so much.</p>

<!--more-->

<p>One of the ideas from <a href="https://www.goodreads.com/book/show/39996759-a-philosophy-of-software-design">A Philosophy of Software Design</a> that struck me was that good method interfaces should be shallow. Fewer parameters make it more obvious how to use a method - less cognitive load incentivizes folks to actually use your API. Fewer parameters mean less levers, so lower chance of misuse. Fewer parameters reduce coupling, making method changes less risky.</p>

<p>Resist the urge to add yet another parameter to your API. Your users will thank you.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Didn’t think a tech book on fundamentals could strike me so much.]]></summary></entry><entry><title type="html">Run Python functions on K8s</title><link href="/2025/08/02/run-python-functions-on-k8s.html" rel="alternate" type="text/html" title="Run Python functions on K8s" /><published>2025-08-02T00:00:00+00:00</published><updated>2025-08-02T00:00:00+00:00</updated><id>/2025/08/02/run-python-functions-on-k8s</id><content type="html" xml:base="/2025/08/02/run-python-functions-on-k8s.html"><![CDATA[<p>Inventing once more. This time, a decorator to run Python functions on K8s, straight from notebooks!</p>

<!--more-->

<p>I love Kubernetes — for production, it’s rock-solid. But throw me a notebook that needs 0.5 TB of RAM and a GPU, and suddenly K8s feels like a chore.</p>

<p>Here’s what that usually means:</p>

<ol>
  <li>Write a Dockerfile.</li>
  <li>Build the image.</li>
  <li>Push it to a registry.</li>
  <li>Write a deployment spec.</li>
  <li>Deploy it to the cluster.</li>
  <li>Set up secrets and environment variables.</li>
  <li>Wait for the pod to start.</li>
  <li>Port forward to Jupyter within the pod.</li>
  <li>Finally, run the notebook.</li>
</ol>

<p>This gets even more interesting if you forget to shutdown the pod. 24 * 10 USD / h = <strong>240 USD / day</strong>, down the drain 🤦.</p>

<p>Meet <a href="https://github.com/astronautas/kuberun">kuberun</a>.<br />
Just decorate your function with <code class="language-plaintext highlighter-rouge">@kuberun</code> and it will run on a Kubernetes cluster as if it were local.  It quietly handles the Docker build, push, deployment, and wiring — you just write Python:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">kuberun</span><span class="p">(</span><span class="n">requirements</span><span class="o">=</span><span class="p">[</span><span class="s">"torch"</span><span class="p">,</span> <span class="s">"transformers"</span><span class="p">,</span> <span class="s">"pillow"</span><span class="p">],</span> <span class="n">cpu</span><span class="o">=</span><span class="mi">32</span><span class="p">,</span> <span class="n">mem</span><span class="o">=</span><span class="s">"64Gi"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">image_contents</span><span class="p">(</span><span class="n">image_bytes</span><span class="p">):</span>
    <span class="kn">from</span> <span class="nn">transformers</span> <span class="kn">import</span> <span class="n">pipeline</span>
    <span class="kn">from</span> <span class="nn">PIL</span> <span class="kn">import</span> <span class="n">Image</span>
    <span class="kn">import</span> <span class="nn">io</span>
    <span class="kn">import</span> <span class="nn">torch</span>

    <span class="c1"># Load and convert the image bytes to PIL
</span>    <span class="n">image</span> <span class="o">=</span> <span class="n">Image</span><span class="p">.</span><span class="nb">open</span><span class="p">(</span><span class="n">io</span><span class="p">.</span><span class="n">BytesIO</span><span class="p">(</span><span class="n">image_bytes</span><span class="p">))</span>
    <span class="k">if</span> <span class="n">image</span><span class="p">.</span><span class="n">mode</span> <span class="o">!=</span> <span class="s">"RGB"</span><span class="p">:</span>
        <span class="n">image</span> <span class="o">=</span> <span class="n">image</span><span class="p">.</span><span class="n">convert</span><span class="p">(</span><span class="s">"RGB"</span><span class="p">)</span>

    <span class="c1"># Choose a captioning model—e.g. BLIP or ViT‑GPT2
</span>    <span class="n">captioner</span> <span class="o">=</span> <span class="n">pipeline</span><span class="p">(</span><span class="s">"image-to-text"</span><span class="p">,</span> <span class="n">model</span><span class="o">=</span><span class="s">"Salesforce/blip-image-captioning-base"</span><span class="p">)</span>
    <span class="c1"># Alternatively: model="nlpconnect/vit-gpt2-image-captioning"
</span>
    <span class="c1"># Run the pipeline
</span>    <span class="n">outputs</span> <span class="o">=</span> <span class="n">captioner</span><span class="p">(</span><span class="n">image</span><span class="p">)</span>

    <span class="c1"># outputs is a list of dicts like [{'generated_text': "..."}]
</span>    <span class="n">caption</span> <span class="o">=</span> <span class="n">outputs</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="s">"generated_text"</span><span class="p">]</span>

    <span class="k">return</span> <span class="p">{</span><span class="s">"description"</span><span class="p">:</span> <span class="n">caption</span><span class="p">}</span>


<span class="p">...</span>

<span class="n">caption</span> <span class="o">=</span> <span class="n">image_contents</span><span class="p">(</span><span class="n">image_bytes</span><span class="p">)[</span><span class="s">"description"</span><span class="p">]</span>
</code></pre></div></div>

<p>As soon as the function returns, the pod and any related resources get immediately cleaned-up.</p>

<p>Zooming in:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">kuberun</span><span class="p">(</span><span class="n">requirements</span><span class="o">=</span><span class="p">[</span><span class="s">"torch"</span><span class="p">,</span> <span class="s">"transformers"</span><span class="p">,</span> <span class="s">"pillow"</span><span class="p">],</span> <span class="n">cpu</span><span class="o">=</span><span class="mi">32</span><span class="p">,</span> <span class="n">mem</span><span class="o">=</span><span class="s">"64Gi"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">you_func</span><span class="p">(</span><span class="n">arg</span><span class="p">):</span>
    <span class="p">...</span>
</code></pre></div></div>

<p>Under the hood, it builds a rather standard templated Docker image and generates a just enough Kubernetes Pod spec. The image is pushed to the configured registry, and the spec is applied to the cluster (within a default namespace, configurable though). Arguments and results are pickled — like in Ray or Python’s <code class="language-plaintext highlighter-rouge">multiprocessing</code>— and exchanged through a shared volume with a sidecar container.</p>

<p>Builds are very slow, and I haven’t figured out annotations, secrets, environment variables yet (without manual spec writing, which defeats the purpose). Still, the concept gives me energy, so give it a try—and share your feedback via an <a href="https://github.com/astronautas/run_on_k8s/issues">issue</a>!</p>

<p>Happy experimentation!</p>

<hr />

<p><small><sup>1</sup>  I sort of accidentally found out that the name <code class="language-plaintext highlighter-rouge">kuberun</code> had been already used for some fancy <a href="https://github.com/ContainerSSH/kuberun">ssh to container tool</a>. It seems to be deprecated. Fair if I borrow the name. It’s a pet project after all…</small></p>]]></content><author><name></name></author><summary type="html"><![CDATA[Inventing once more. This time, a decorator to run Python functions on K8s, straight from notebooks!]]></summary></entry><entry><title type="html">Should we replace Spark with DuckDB?</title><link href="/2025/02/11/replace-spark-with-duckdb.html" rel="alternate" type="text/html" title="Should we replace Spark with DuckDB?" /><published>2025-02-11T00:00:00+00:00</published><updated>2025-02-11T00:00:00+00:00</updated><id>/2025/02/11/replace-spark-with-duckdb</id><content type="html" xml:base="/2025/02/11/replace-spark-with-duckdb.html"><![CDATA[<p>As we’ll see, only for some queries (but we can use ML to predict for which ones!).</p>

<!--more-->

<p>I was attending a DuckDB meetup in Leuven, where a presenter was detailing how they replaced Spark with DuckDB to cut costs, when rather abruptly, someone in the audience asked, “How do I know if I should really use DuckDB now, me being not a data engineer? What if my query is heavier than expected? What if my data fluctuates and just sometimes goes beyond a single machine?”. The presenter admitted that you set up the architecture once, do your best benchmarking, and hope for the data dynamics to stay the same.</p>

<p>I was intrigued, but not really convinced, yet.</p>

<h2 id="catalog-is-the-bridge">Catalog is the bridge</h2>

<p>Next day after the event, I dove into the idea that the query engine should be automatically selected based on the query itself. Having some scars from hefty BigQuery bills, I built a SQL executor that switched between DuckDB and BigQuery. The challenge though was that I couldn’t use the same query with both engines due to the <strong>lack of shared catalog</strong> support.</p>

<p>Fast forward a year, and we now have <strong>Unity Catalog support for DuckDB and Polars</strong> — yes, the same catalog well supported by distributed engines like Spark and platforms like Databricks. Since we only care about read-only access, I think we can go pretty far with this.</p>

<p>I quickly threw together a prototype — let’s see it in action:</p>

<p>1) Launch the Unity catalog:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>git clone https://github.com/unitycatalog/unitycatalog.git unity-catalog 
<span class="nv">$ </span><span class="nb">cd </span>unity-catalog
<span class="nv">$ </span>bin/start-uc-server
</code></pre></div></div>

<p>should start it at <code class="language-plaintext highlighter-rouge">:8080</code>.</p>

<p>2) Load a decent chunk <a href="https://benchmark.clickhouse.com/">Clickbench</a> data into UC:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">spark</span><span class="p">.</span><span class="n">read</span><span class="p">.</span><span class="n">parquet</span><span class="p">(</span><span class="s">"data/hits.parquet"</span><span class="p">)</span> \
    <span class="p">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">5_000_000</span><span class="p">)</span> \
    <span class="p">.</span><span class="n">write</span> \
    <span class="p">.</span><span class="nb">format</span><span class="p">(</span><span class="s">"delta"</span><span class="p">)</span> \
    <span class="p">.</span><span class="n">option</span><span class="p">(</span><span class="s">"overwriteSchema"</span><span class="p">,</span> <span class="s">"true"</span><span class="p">)</span> \
    <span class="p">.</span><span class="n">mode</span><span class="p">(</span><span class="s">"overwrite"</span><span class="p">)</span> \
    <span class="p">.</span><span class="n">option</span><span class="p">(</span><span class="s">'path'</span><span class="p">,</span> <span class="s">'&lt;abs_path&gt;/hits_delta'</span><span class="p">)</span> \
    <span class="p">.</span><span class="n">saveAsTable</span><span class="p">(</span><span class="s">"default.hits"</span><span class="p">)</span>
</code></pre></div></div>

<p>that’s all it takes to expose a Unity Catalog table backed by a Delta table (files) to other query engines!</p>

<p>3) Pull-in <a href="https://github.com/astronautas/frugal-engine">frugal-engine</a>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>pip <span class="nb">install </span>git+https://github.com/astronautas/frugal-engine
</code></pre></div></div>

<p>4) Set-up the executor:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">logging</span>
<span class="kn">from</span> <span class="nn">frugal_engine.main</span> <span class="kn">import</span> <span class="n">setup</span>

<span class="n">logging</span><span class="p">.</span><span class="n">basicConfig</span><span class="p">(</span><span class="n">level</span><span class="o">=</span><span class="n">logging</span><span class="p">.</span><span class="n">INFO</span><span class="p">)</span>
<span class="n">setup</span><span class="p">()</span>
</code></pre></div></div>

<p>This sets up the hybrid engine with a single-node executor and a cluster executor as a fallback.</p>

<p>Now, let’s run a few experiments 🧪. We’ll try a simple select first:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># %%sequel deliberately, not to interfere with %%sql
</span><span class="o">%%</span><span class="n">sequel</span> 
<span class="n">SELECT</span> <span class="o">*</span> 
<span class="n">FROM</span> <span class="n">unity</span><span class="p">.</span><span class="n">default</span><span class="p">.</span><span class="n">hits</span> <span class="n">LIMIT</span> <span class="mi">10</span>
</code></pre></div></div>

<p>You should see in the logs that it picked a single node executor since the data size is relatively small:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INFO:frugal_engine.main:single_node_executor has been chosen, because the top <span class="nb">source </span>count is less than or equal to the threshold: 6000000
INFO:frugal_engine.main:Executing query <span class="k">in </span>single_node_executor: SELECT <span class="k">*</span> 
FROM unity.default.hits LIMIT 10
</code></pre></div></div>

<p>Let’s assume our machine is tiny and lower the threshold for single-node workflows:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">frugal_engine.main</span> <span class="kn">import</span> <span class="n">setup</span>
<span class="n">setup</span><span class="p">(</span><span class="n">threshold</span><span class="o">=</span><span class="mi">100_000</span><span class="p">)</span>

<span class="o">%%</span><span class="n">sequel</span> 
<span class="n">SELECT</span> <span class="o">*</span> 
<span class="n">FROM</span> <span class="n">unity</span><span class="p">.</span><span class="n">default</span><span class="p">.</span><span class="n">hits</span> <span class="n">LIMIT</span> <span class="mi">10</span>

<span class="n">INFO</span><span class="p">:</span><span class="n">frugal_engine</span><span class="p">.</span><span class="n">main</span><span class="p">:</span><span class="n">Executing</span> <span class="n">query</span> <span class="ow">in</span> <span class="n">cluster_executor</span><span class="p">:</span> <span class="n">SELECT</span> <span class="o">*</span> 
<span class="n">FROM</span> <span class="n">unity</span><span class="p">.</span><span class="n">default</span><span class="p">.</span><span class="n">hits</span> <span class="n">LIMIT</span> <span class="mi">10</span>
</code></pre></div></div>

<p>From the logs, we see it falls back to a cluster engine since it “thinks” a single machine won’t process that many rows in a reasonable time. It should also fall back if the single-node executor reaches its memory limits—or even proactively, using ML to forecast that it won’t finish in a reasonable time.</p>

<p>Identical SQL, totally different engines! Convenient-ish to use - check. What about the promise of improved performance / reduced costs?</p>

<h2 id="quantifying-the-cost-savings">Quantifying the cost savings</h2>

<p>We can’t draw firm conclusions on costs yet, as proper testing with a Spark cluster, Delta access from S3, load full Clickbench dataset - it’s a complex evaluation. I’ll tackle this in the next chapter.</p>

<p>With usability and (some) cost aspects explored, let’s examine what makes the package tick.</p>

<h2 id="duckdb-uc---not-quite-there-yet">DuckDB UC - not quite there yet</h2>
<p>I initially tried to use DuckDB for the single-node executor, but… it didn’t go well.</p>

<p>I was so hyped by <a href="https://www.youtube.com/watch?v=oxPFt_HEFAc">many</a> <a href="https://www.youtube.com/watch?v=oxPFt_HEFAc">talks</a> about how DuckDB should at least have read-only support for Unity Catalog. It works fine for pre-loaded OSS UC tables:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">duckdb</span>
<span class="kn">import</span> <span class="nn">logging</span>

<span class="n">duckdb</span><span class="p">.</span><span class="n">sql</span><span class="p">(</span><span class="s">"""
	install uc_catalog from core_nightly;
	load uc_catalog;
	install delta;
	load delta;
"""</span><span class="p">)</span>

<span class="n">duckdb</span><span class="p">.</span><span class="n">sql</span><span class="p">(</span><span class="sa">f</span><span class="s">"""
CREATE SECRET (
	TYPE UC,
	TOKEN 'not-used',
	ENDPOINT 'http://127.0.0.1:8080',
	AWS_REGION 'us-east-2'
);
"""</span><span class="p">)</span>

<span class="n">duckdb</span><span class="p">.</span><span class="n">sql</span><span class="p">(</span><span class="s">"ATTACH 'unity' AS unity (TYPE UC_CATALOG);"</span><span class="p">)</span>


<span class="n">logging</span><span class="p">.</span><span class="n">basicConfig</span><span class="p">(</span><span class="n">level</span><span class="o">=</span><span class="n">logging</span><span class="p">.</span><span class="n">DEBUG</span><span class="p">)</span>
<span class="n">duckdb</span><span class="p">.</span><span class="n">sql</span><span class="p">(</span><span class="sa">f</span><span class="s">"select * from unity.default.marksheet limit 10;"</span><span class="p">)</span>
</code></pre></div></div>

<p>But as soon as you commit something to UC via another engine, like Spark, DuckDB halts:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">IOException</span> <span class="n">Traceback</span> <span class="p">(</span><span class="n">most</span> <span class="n">recent</span> <span class="n">call</span> <span class="n">last</span><span class="p">)</span> <span class="n">Cell</span> <span class="n">In</span><span class="p">[</span><span class="mi">4</span><span class="p">],</span> <span class="n">line</span> <span class="mi">4</span> <span class="mi">1</span> <span class="kn">import</span> <span class="nn">logging</span> <span class="mi">3</span> <span class="n">logging</span><span class="p">.</span><span class="n">basicConfig</span><span class="p">(</span><span class="n">level</span><span class="o">=</span><span class="n">logging</span><span class="p">.</span><span class="n">DEBUG</span><span class="p">)</span> <span class="o">----&gt;</span> <span class="mi">4</span> <span class="n">duckdb</span><span class="p">.</span><span class="n">sql</span><span class="p">(</span><span class="sa">f</span><span class="s">"select * from unity.default.hits_2 limit 10;"</span><span class="p">)</span> 

<span class="n">IOException</span><span class="p">:</span> <span class="n">IO</span> <span class="n">Error</span><span class="p">:</span> <span class="n">Invalid</span> <span class="n">field</span> <span class="n">found</span> <span class="k">while</span> <span class="n">parsing</span> <span class="n">field</span><span class="p">:</span> <span class="n">type_precision</span>

<span class="n">CatalogException</span> <span class="n">Traceback</span> <span class="p">(</span><span class="n">most</span> <span class="n">recent</span> <span class="n">call</span> <span class="n">last</span><span class="p">)</span> <span class="n">Cell</span> <span class="n">In</span><span class="p">[</span><span class="mi">5</span><span class="p">],</span> <span class="n">line</span> <span class="mi">4</span> <span class="mi">1</span> <span class="kn">import</span> <span class="nn">logging</span> <span class="mi">3</span> <span class="n">logging</span><span class="p">.</span><span class="n">basicConfig</span><span class="p">(</span><span class="n">level</span><span class="o">=</span><span class="n">logging</span><span class="p">.</span><span class="n">DEBUG</span><span class="p">)</span> <span class="o">----&gt;</span> <span class="mi">4</span> <span class="n">duckdb</span><span class="p">.</span><span class="n">sql</span><span class="p">(</span><span class="sa">f</span><span class="s">"select * from unity.default.hits_2 limit 10;"</span><span class="p">)</span> 

<span class="n">CatalogException</span><span class="p">:</span> <span class="n">Catalog</span> <span class="n">Error</span><span class="p">:</span> <span class="n">Table</span> <span class="k">with</span> <span class="n">name</span> <span class="n">hits_2</span> <span class="n">does</span> <span class="ow">not</span> <span class="n">exist</span><span class="err">!</span> <span class="n">Did</span> <span class="n">you</span> <span class="n">mean</span> <span class="s">"information_schema.views"</span><span class="err">?</span>
</code></pre></div></div>

<p>I spent hours trying to figure it out. Fair enough, the extension is tagged as experimental, and there’s a related <a href="https://github.com/duckdb/uc_catalog/issues/8">GitHub issue</a>, but I expected clearer error messages. 👎</p>

<h2 id="polars-to-the-rescue">Polars to the rescue</h2>

<p>Polars, on the other hand, works flawlessly! Unlike DuckDB, you can’t just attach a catalog—there’s no dedicated Polars client or anything like <code class="language-plaintext highlighter-rouge">polars.sql</code> yet. To achieve this, we’ll create a wrapper around Polars UC:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">PolarsClient</span><span class="p">:</span>
    <span class="s">"""
    Just an interface similar to Spark and DuckDB i.e. exposing sql, but going through UC catalog
    """</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">unity_catalog_uri</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">memory_limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="bp">None</span><span class="p">):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">unity_catalog_uri</span> <span class="o">=</span> <span class="n">unity_catalog_uri</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">catalog</span> <span class="o">=</span> <span class="n">pl</span><span class="p">.</span><span class="n">Catalog</span><span class="p">(</span><span class="n">unity_catalog_uri</span><span class="p">,</span> <span class="n">require_https</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span> <span class="c1"># last flag - for local testing
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">memory_limit</span> <span class="o">=</span> <span class="bp">None</span>

    <span class="k">def</span> <span class="nf">sql</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">query</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">pl</span><span class="p">.</span><span class="n">DataFrame</span><span class="p">:</span>
        <span class="n">tables</span> <span class="o">=</span> <span class="p">[</span><span class="n">table</span> <span class="k">for</span> <span class="n">table</span> <span class="ow">in</span> <span class="n">sqlglot</span><span class="p">.</span><span class="n">parse_one</span><span class="p">(</span><span class="n">query</span><span class="p">).</span><span class="n">find_all</span><span class="p">(</span><span class="n">sqlglot</span><span class="p">.</span><span class="n">exp</span><span class="p">.</span><span class="n">Table</span><span class="p">)]</span>

        <span class="k">assert</span> <span class="nb">len</span><span class="p">(</span><span class="n">tables</span><span class="p">)</span> <span class="o">==</span> <span class="mi">1</span><span class="p">,</span> <span class="s">"Only one table is supported as a source for now"</span>

        <span class="n">df_scanned</span> <span class="o">=</span> <span class="p">[</span><span class="bp">self</span><span class="p">.</span><span class="n">catalog</span><span class="p">.</span><span class="n">scan_table</span><span class="p">(</span><span class="nb">str</span><span class="p">(</span><span class="n">table</span><span class="p">.</span><span class="n">catalog</span><span class="p">),</span> <span class="nb">str</span><span class="p">(</span><span class="n">table</span><span class="p">.</span><span class="n">db</span><span class="p">),</span> <span class="nb">str</span><span class="p">(</span><span class="n">table</span><span class="p">.</span><span class="n">this</span><span class="p">))</span> <span class="k">for</span> <span class="n">table</span> <span class="ow">in</span> <span class="n">tables</span><span class="p">]</span>
        <span class="n">df_scanned</span> <span class="o">=</span> <span class="n">df_scanned</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>

        <span class="n">var_name</span> <span class="o">=</span> <span class="nb">next</span><span class="p">(</span><span class="n">name</span> <span class="k">for</span> <span class="n">name</span><span class="p">,</span> <span class="n">val</span> <span class="ow">in</span> <span class="nb">locals</span><span class="p">().</span><span class="n">items</span><span class="p">()</span> <span class="k">if</span> <span class="n">val</span> <span class="ow">is</span> <span class="n">df_scanned</span><span class="p">)</span>

        <span class="n">query_for_pl</span> <span class="o">=</span> <span class="n">sqlglot</span><span class="p">.</span><span class="n">parse_one</span><span class="p">(</span><span class="n">query</span><span class="p">).</span><span class="n">from_</span><span class="p">(</span><span class="n">var_name</span><span class="p">).</span><span class="n">sql</span><span class="p">()</span>
        <span class="k">return</span> <span class="n">pl</span><span class="p">.</span><span class="n">sql</span><span class="p">(</span><span class="n">query_for_pl</span><span class="p">).</span><span class="n">collect</span><span class="p">()</span>
</code></pre></div></div>

<p>See what we did here? We used <code class="language-plaintext highlighter-rouge">sqlglot</code> to retrieve the source tables, pre-load them into <code class="language-plaintext highlighter-rouge">LazyFrame</code>, and replace references in the original SQL query. Nasty, but it gets the job done!</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">setup</span><span class="p">(</span><span class="n">unity_catalog_uri</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s">"http://localhost:8080"</span><span class="p">,</span> <span class="n">threshold</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">6_000_000</span><span class="p">):</span>
    <span class="n">spark</span> <span class="o">=</span> <span class="n">_build_local_spark_client</span><span class="p">(</span><span class="n">unity_catalog_uri</span><span class="p">)</span>
    <span class="n">polars_client</span> <span class="o">=</span> <span class="n">PolarsClient</span><span class="p">(</span><span class="n">unity_catalog_uri</span><span class="p">)</span>
    <span class="n">client</span> <span class="o">=</span> <span class="n">HybridClient</span><span class="p">(</span><span class="n">spark_client</span><span class="o">=</span><span class="n">spark</span><span class="p">,</span> <span class="n">polars_client</span><span class="o">=</span><span class="n">polars_client</span><span class="p">,</span> <span class="n">threshold</span><span class="o">=</span><span class="n">threshold</span><span class="p">)</span>

    <span class="c1"># non-invasive way to add a magic function
</span>    <span class="n">get_ipython</span><span class="p">().</span><span class="n">register_magic_function</span><span class="p">(</span><span class="k">lambda</span> <span class="n">line</span><span class="p">,</span> <span class="n">cell</span><span class="o">=</span><span class="bp">None</span><span class="p">:</span> <span class="n">client</span><span class="p">.</span><span class="n">sql</span><span class="p">(</span><span class="n">line</span> <span class="ow">or</span> <span class="n">cell</span><span class="p">),</span>
        <span class="n">magic_kind</span><span class="o">=</span><span class="s">"line_cell"</span><span class="p">,</span> <span class="n">magic_name</span><span class="o">=</span><span class="s">"sequel"</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">setup</code> isn’t that interesting—it just introduces a new magic function, <code class="language-plaintext highlighter-rouge">sequel</code>, that delegates queries to <code class="language-plaintext highlighter-rouge">HybridClient</code>. The real interesting part is engine the decision function.</p>

<h2 id="using-query-information-to-select-the-best-engine">Using query information to select the best engine</h2>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="k">def</span> <span class="nf">_count_heuristic</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">query</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Choice</span><span class="p">:</span>
        <span class="n">tables</span> <span class="o">=</span> <span class="p">[</span><span class="n">table</span> <span class="k">for</span> <span class="n">table</span> <span class="ow">in</span> <span class="n">parse_one</span><span class="p">(</span><span class="n">query</span><span class="p">).</span><span class="n">find_all</span><span class="p">(</span><span class="n">exp</span><span class="p">.</span><span class="n">Table</span><span class="p">)]</span>
        <span class="n">counts</span> <span class="o">=</span> <span class="p">[</span><span class="bp">self</span><span class="p">.</span><span class="n">polars_client</span><span class="p">.</span><span class="n">sql</span><span class="p">(</span><span class="sa">f</span><span class="s">"select count(*) from </span><span class="si">{</span><span class="n">table</span><span class="p">.</span><span class="n">catalog</span><span class="si">}</span><span class="s">.</span><span class="si">{</span><span class="n">table</span><span class="p">.</span><span class="n">db</span><span class="si">}</span><span class="s">.</span><span class="si">{</span><span class="n">table</span><span class="p">.</span><span class="n">name</span><span class="si">}</span><span class="s">"</span><span class="p">).</span><span class="n">to_series</span><span class="p">()[</span><span class="mi">0</span><span class="p">]</span> <span class="k">for</span> <span class="n">table</span> <span class="ow">in</span> <span class="n">tables</span><span class="p">]</span>
        <span class="n">top_count</span> <span class="o">=</span> <span class="nb">max</span><span class="p">(</span><span class="n">counts</span><span class="p">)</span>

        <span class="n">logger</span><span class="p">.</span><span class="n">debug</span><span class="p">(</span><span class="sa">f</span><span class="s">"Top source count: </span><span class="si">{</span><span class="n">top_count</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
        
        <span class="k">if</span> <span class="n">top_count</span> <span class="o">&lt;=</span> <span class="bp">self</span><span class="p">.</span><span class="n">threshold</span><span class="p">:</span>
            <span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">Choice</span><span class="p">.</span><span class="n">POLARS</span><span class="p">.</span><span class="n">value</span><span class="si">}</span><span class="s"> has been chosen, because the top source count is less than or equal to the threshold: </span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">threshold</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
            <span class="k">return</span> <span class="n">Choice</span><span class="p">.</span><span class="n">POLARS</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">Choice</span><span class="p">.</span><span class="n">SPARK</span><span class="p">.</span><span class="n">value</span><span class="si">}</span><span class="s"> has been chosen, because the top source count is greater than the threshold: </span><span class="si">{</span><span class="bp">self</span><span class="p">.</span><span class="n">threshold</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
            <span class="k">return</span> <span class="n">Choice</span><span class="p">.</span><span class="n">SPARK</span>
</code></pre></div></div>

<p>Essentially, we select the most appropriate engine for the query based on source table statistic - the row count - and then delegate. It’s a simple heuristic, but it illustrates the point.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="n">choice</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">_count_heuristic</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
    
    <span class="k">if</span> <span class="n">choice</span> <span class="o">==</span> <span class="n">Choice</span><span class="p">.</span><span class="n">POLARS</span><span class="p">:</span>
        <span class="k">try</span><span class="p">:</span>
            <span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"Executing query in </span><span class="si">{</span><span class="n">Choice</span><span class="p">.</span><span class="n">POLARS</span><span class="p">.</span><span class="n">value</span><span class="si">}</span><span class="s">: </span><span class="si">{</span><span class="n">query</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
            <span class="c1"># polars - unsupported dialect, duckdb might be closest?
</span>            <span class="n">query</span> <span class="o">=</span> <span class="n">sqlglot</span><span class="p">.</span><span class="n">transpile</span><span class="p">(</span><span class="n">sql</span><span class="o">=</span><span class="n">query</span><span class="p">,</span> <span class="n">write</span><span class="o">=</span><span class="s">"duckdb"</span><span class="p">)[</span><span class="mi">0</span><span class="p">]</span>
            <span class="k">return</span> <span class="bp">self</span><span class="p">.</span><span class="n">polars_client</span><span class="p">.</span><span class="n">sql</span><span class="p">(</span><span class="n">query</span><span class="p">).</span><span class="n">to_pandas</span><span class="p">()</span>
        <span class="c1"># fixme - too generic exception
</span>        <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
            <span class="n">logger</span><span class="p">.</span><span class="n">error</span><span class="p">(</span><span class="sa">f</span><span class="s">"Error: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s">, fallbacking to </span><span class="si">{</span><span class="n">Choice</span><span class="p">.</span><span class="n">SPARK</span><span class="p">.</span><span class="n">value</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>                
            <span class="n">query</span> <span class="o">=</span> <span class="n">sqlglot</span><span class="p">.</span><span class="n">transpile</span><span class="p">(</span><span class="n">sql</span><span class="o">=</span><span class="n">query</span><span class="p">,</span> <span class="n">write</span><span class="o">=</span><span class="s">"spark"</span><span class="p">)[</span><span class="mi">0</span><span class="p">]</span>

        <span class="k">return</span> <span class="bp">self</span><span class="p">.</span><span class="n">spark_client</span><span class="p">.</span><span class="n">sql</span><span class="p">(</span><span class="n">query</span><span class="p">).</span><span class="n">toPandas</span><span class="p">()</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"Executing query in </span><span class="si">{</span><span class="n">Choice</span><span class="p">.</span><span class="n">SPARK</span><span class="p">.</span><span class="n">value</span><span class="si">}</span><span class="s">: </span><span class="si">{</span><span class="n">query</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
        <span class="k">return</span> <span class="bp">self</span><span class="p">.</span><span class="n">spark_client</span><span class="p">.</span><span class="n">sql</span><span class="p">(</span><span class="n">query</span><span class="p">).</span><span class="n">toPandas</span><span class="p">()</span>
</code></pre></div></div>

<p>We use <code class="language-plaintext highlighter-rouge">sqlglot</code> to transpile between dialects, but unfortunately Polars SQL is not a supported dialect yet. Finally, if you look closely, we fallback to the default distributed Spark executor if we encouter an unforseen issue - e.g. unsupported SQL operation, memory issues, etc.</p>

<h2 id="next-step--databricks">Next Step – Databricks</h2>

<p>Hope you at least got intrigued by the idea that it’s possible to balance the benefits of single and  cluster type query engines without changing analyst workflows. Next, when I have time, I’ll extend this to Databricks and test cost savings in a more realistic production setting - think Spark cluster, Delta tables in S3, annoying authentication issues, etc.</p>

<p>Stay tuned!</p>]]></content><author><name></name></author><summary type="html"><![CDATA[As we’ll see, only for some queries (but we can use ML to predict for which ones!).]]></summary></entry><entry><title type="html">FOSDEM 2025 has exceeded my expectations</title><link href="/2025/02/02/fosdem-25.html" rel="alternate" type="text/html" title="FOSDEM 2025 has exceeded my expectations" /><published>2025-02-02T00:00:00+00:00</published><updated>2025-02-02T00:00:00+00:00</updated><id>/2025/02/02/fosdem-25</id><content type="html" xml:base="/2025/02/02/fosdem-25.html"><![CDATA[<p>Commercial conferences don’t hold a chance against it. Arrow, WASM, Graph DBs, Distributed Systems, CNCF, and more.</p>

<!--more-->

<p>This weekend, I participated in FOSDEM 2025, which impressed me more than I had imagined. Boy, was it big—over 1,000 talks! Here are a few notable takeaways:</p>

<h3 id="apache-arrow-is-a-top-dog-with-a-turbulent-future">Apache Arrow is a top dog, with a turbulent future</h3>
<p>The first panel I attended on day one was Apache Arrow’s. It felt surreal to meet the contributors face-to-face—they all seemed like genuinely nice people who simply know their niche very well.</p>

<p><img src="/assets/IMG_2337.jpg" alt="img" /></p>
<center><i>Arrow is the standard in-memory data format for interoperability between languages.</i></center>

<p><br /></p>

<p>I’ve been aware of Arrow for a few years, and this panel only reinforced my confidence in it as the standard in-memory analytics data format for interoperability between languages, processes, and systems. With identically laid-out bytes in memory (based on their spec) for Python, Go, Rust—heck, even Java (crazy, right?) — you can operate on the same shared memory block across different processes and even different stacks without any serialization or deserialization. This is why DuckDB can pass data to Pandas without any overhead, why Spark (with the Arrow option enabled) can pass data to Pandas without any overhead, and why Polars can do the same.</p>

<p>An additional benefit is that it’s columnar, meaning bytes are laid out in a way that allows highly efficient operations on single or multiple columns (e.g., sum, count, etc.). As an example, Polars leverages it as a in-memory storage format with great success.</p>

<p>→ Arrow enables different languages, processes, and systems do work on and communicate at maximum speed — think of it as the English language for software.</p>

<p><img src="/assets/IMG_2336.jpg" alt="img" /></p>
<center><i>Arrow columnar format is the way more and more engines hold the data in memory, and for a good (interop) reason.</i></center>

<p><br /></p>

<p>Another cool application they mentioned was using Arrow at Hugging Face: efficiently handling data larger than memory for LLMs with their Datasets library. We all know how to stream data from disk when RAM is limited - implementing reading in batches - but with Arrow, you can dump memory bytes to disk 1:1 (pure bytes without any changes) and memory-map the disk to “pretend” it’s RAM — kind of similar to swapping - but with efficient read-in since no deserialization occurs. So, basically, you dump bytes into a <code class="language-plaintext highlighter-rouge">.arrow</code> file (with PyArrow), straight into <code class="language-plaintext highlighter-rouge">/tmp/</code>, and then <code class="language-plaintext highlighter-rouge">mmap</code>, which is much faster and less code than doing the same with Parquet files. Extremely useful for loading datasets larger than memory in a convenient fashion.</p>

<p>A bit of drama — Voltron Data, the main contributor to Arrow, was hit by layoffs last year. The majority of its staff had to find other jobs, meaning a significant portion of Arrow maintainers left. People expressed their commitment to finding ways to support the Arrow project (e.g., through foundations) to ensure its continued maintenance, but they seemed quite concerned nonetheless.</p>

<p>With Arrow, nothing prevents us from implementing a dataframe library in Go, right?</p>

<h3 id="graph-databases-will-not-conquer-the-world-but-they-have-their-niches">Graph databases will not conquer the world, but they have their niches</h3>
<p>Great historical talk by one of DuckDB’s developer advocates, Gábor Szárnyas, who is apparently a reputable graph database researcher as well! Key takeaway: graph databases excel at nested/recursive multi-join problems because (a) writing SQL for something like the shortest path quickly turns into a mess of LISTAGG, multiple joins, etc., and (b) they leverage factorization under the hood—flattening certain columns into lists to reduce joins (e.g., storing a person’s friend list as a list instead of separate rows). This makes them dominant in this niche.</p>

<p><img src="/assets/image-graph.png" alt="img" /></p>
<center><i>Implementing shortest path finding in SQL is no one-liner. A slide from Gábor's talk.</i></center>

<p><br />
However, graph DBs won’t replace RDMS - they are neither faster (usually since they are schemaless), nor better supported (very fragmented landscape due to overfitting to specific use-cases).</p>

<p>On a positive note, his group has made massive leaps on a graph DB benchmarking suite, which should move the field forward either way.</p>

<h3 id="opentelemetry---insert-your-favorite-olap-engine--is-the-way-to-go">Opentelemetry + | insert your favorite OLAP engine | is the way to go</h3>
<p>There was a talk from a Clickhouse developer advocate, who once more preached Clickhouse for telemetry. He had a point though.</p>

<p>I don’t have years of SRE experience to make any strong recommendations here, but having dabbled a bit with the classic Prometheus + Grafana setup, I’ve noticed two issues:</p>

<ol>
  <li>PromQL is a terrible language—hard to read and write, especially compared to SQL.</li>
  <li>Prometheus time-series DB is relatively slow, given the sheer amount of data it has to process.</li>
  <li>Prometheus instrumentation client is okay, but why lock yourself into Prometheus when you can use OpenTelemetry and switch between different backends?</li>
</ol>

<p>OK, you may have to set-up some things e.g. a UI, but it’s not the hard part i.e. you can set-up Granana with some SQL sources e.g. the Clickhouse, and you’re good to go.</p>

<p>From my perspective, it’s just that we have ingrained habits of setting up these telemetry stacks. I have to agree with the overly excited ClickHouse presenters: the future telemetry stack is OpenTelemetry (to reduce vendor lock-in) combined with any real-time OLAP database, like ClickHouse. Sums, counts, percentiles, etc., are OLAP’s bread and butter—typically all one- or multi-column operations.</p>

<p>Will benchmark sometime soon. This entire area can be IMHO disrupted™.</p>

<h3 id="wasm-will-not-replace-containers-but-it-is-the-definition-of-cool">Wasm will not replace containers, but it is the definition of cool</h3>
<p>Dan Phillips showcased a pretty cool project he’s been developing, which essentially takes a Dockerfile, runs <code class="language-plaintext highlighter-rouge">box build -f Dockerfile</code>, and produces a WASM binary that can even run in a browser. In general, this results in much lower startup times and better isolation, which I definitely agree with.</p>

<p>Here’s the catch: no, containers are not disappearing. WASM has slower runtime performance because the WASM runtime acts like a VM executing WASM assembly-like bytecode. They are reimplementing every syscall to match the same interface, which requires an astonishing amount of effort. On top of that, they’re reimplementing every Dockerfile command.</p>

<p>Nonetheless, it’s a cool project, the author seems like a great enthusiastic engineer, and I can see it being used for short-running FAAS (short runtime, but the syscalls are the real challenge).</p>

<h3 id="cncf-is-a-big-deal">CNCF is a big deal</h3>
<p>To be fair, I’d never heard of CNCF, but they’re such a professional non-profit and the parent company of Kubernetes, Prometheus, and many other projects. They shared some thoughts on how they’re not too concerned if some individual contributors disappear for reasons like family commitments, but the bigger issue arises when projects are maintained by a single entity that can just reorganize overnight. Apache Arrow, is that you?</p>

<p>Keep up the good work, CNCF!</p>

<h3 id="distributed-systems-are-again-notoriously-hard-to-build">Distributed Systems are (again) notoriously hard to build</h3>

<p><img src="/assets/IMG_2339.jpg" alt="img" /></p>
<center><i>Distributed state is hard to get right, and even tiny bugs can lead to catastrophic failures.</i></center>

<p><br /></p>

<p>“Was Leslie Lamport Right?” — that’s the title of the main talk in the distributed systems track. Apparently, the legendary analyst and educator in distributed systems even before he had a distributed system at home. I learned a bit about Lamport clocks and how distributed systems sync in discrete and relative time, not wall-clock time. It made me think that offloading as much state as possible to S3 might not be such a bad idea, and that building one should be (and remain) a highly rewarding side project.</p>

<h3 id="fsfe-is-also-a-big-deal">FSFE is also a big deal</h3>
<p>Massive respect for the folks who have been advocating for nearly 20 years for extendable and replaceable software. Although the presenter stirred the pot a bit with the audience — some folks didn’t buy the idea that mobile device software is not modifyable — let’s take Apple, FSFE’s current target - sure, you can change iOS into something open-source, but that’s only feasible if you’re a 10x hobbyist engineer, not regular folk IMHO. Best of luck to them!</p>

<h3 id="conclusion">Conclusion</h3>
<p>The conference beat to dust every commercial conference I have ever been to. It was an eye-opening experience tech-wise and great for meeting old and existing colleagues for some networking. Brussels is the perfect setting for it as well - well connected and just the right European vibes. Great food and drinks too…</p>

<p>Will return next year.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Commercial conferences don’t hold a chance against it. Arrow, WASM, Graph DBs, Distributed Systems, CNCF, and more.]]></summary></entry><entry><title type="html">I tried using wheels as a model package format</title><link href="/2025/01/26/wheel-good-ml-package-format.html" rel="alternate" type="text/html" title="I tried using wheels as a model package format" /><published>2025-01-26T00:00:00+00:00</published><updated>2025-01-26T00:00:00+00:00</updated><id>/2025/01/26/wheel-good-ml-package-format</id><content type="html" xml:base="/2025/01/26/wheel-good-ml-package-format.html"><![CDATA[<p>Inventing a model package format. Spoiler alert: MLFlow is here to stay.</p>

<!--more-->

<p>I’ll give it a few years until MLflow dominates the model package format space, with alternatives like SageMaker models fading away, and sharing pure weights becoming an arcane art. But until that dominance is absolute, I’ve been thinking that there’s another quite obvious way to package models: <strong>just store them as wheels</strong>. Packaging == persisting the trained model.</p>

<p>Let’s create a project to train a model:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>uv init <span class="nt">--lib</span> model
<span class="nv">$ </span><span class="nb">mkdir </span>registry <span class="c"># the model registry</span>
</code></pre></div></div>

<p>Within the model package (<code class="language-plaintext highlighter-rouge">src/model/main.py</code>), we’ll a create dummy
decision tree model, with typical train/predict interface:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">sklearn.datasets</span> <span class="kn">import</span> <span class="n">load_iris</span>
<span class="kn">from</span> <span class="nn">sklearn.tree</span> <span class="kn">import</span> <span class="n">DecisionTreeClassifier</span>
<span class="kn">from</span> <span class="nn">sklearn.model_selection</span> <span class="kn">import</span> <span class="n">train_test_split</span>
<span class="kn">import</span> <span class="nn">joblib</span>
<span class="kn">from</span> <span class="nn">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>
<span class="kn">from</span> <span class="nn">sklearn.preprocessing</span> <span class="kn">import</span> <span class="n">StandardScaler</span>
<span class="kn">import</span> <span class="nn">numpy</span> <span class="k">as</span> <span class="n">np</span>
<span class="kn">from</span> <span class="nn">sklearn.pipeline</span> <span class="kn">import</span> <span class="n">Pipeline</span>

<span class="k">def</span> <span class="nf">train</span><span class="p">():</span>    
    <span class="c1"># Load dataset
</span>    <span class="n">iris</span> <span class="o">=</span> <span class="n">load_iris</span><span class="p">()</span>
    <span class="n">X</span><span class="p">,</span> <span class="n">y</span> <span class="o">=</span> <span class="n">iris</span><span class="p">.</span><span class="n">data</span><span class="p">,</span> <span class="n">iris</span><span class="p">.</span><span class="n">target</span>

    <span class="c1"># Split the dataset into training and testing sets
</span>    <span class="n">X_train</span><span class="p">,</span> <span class="n">X_test</span><span class="p">,</span> <span class="n">y_train</span><span class="p">,</span> <span class="n">y_test</span> <span class="o">=</span> <span class="n">train_test_split</span><span class="p">(</span><span class="n">X</span><span class="p">,</span> <span class="n">y</span><span class="p">,</span> <span class="n">test_size</span><span class="o">=</span><span class="mf">0.2</span><span class="p">,</span> <span class="n">random_state</span><span class="o">=</span><span class="mi">42</span><span class="p">)</span>

    <span class="c1"># Create a pipeline with a scaler and a decision tree classifier
</span>    <span class="n">pipeline</span> <span class="o">=</span> <span class="n">Pipeline</span><span class="p">([</span>
        <span class="p">(</span><span class="s">'scaler'</span><span class="p">,</span> <span class="n">StandardScaler</span><span class="p">()),</span>
        <span class="p">(</span><span class="s">'classifier'</span><span class="p">,</span> <span class="n">DecisionTreeClassifier</span><span class="p">())</span>
    <span class="p">])</span>

    <span class="c1"># Train the pipeline
</span>    <span class="n">pipeline</span><span class="p">.</span><span class="n">fit</span><span class="p">(</span><span class="n">X_train</span><span class="p">,</span> <span class="n">y_train</span><span class="p">)</span>

    <span class="c1"># Dump the trained pipeline to a file
</span>    <span class="n">Path</span><span class="p">(</span><span class="n">Path</span><span class="p">(</span><span class="n">__file__</span><span class="p">).</span><span class="n">parent</span> <span class="o">/</span> <span class="s">'data'</span><span class="p">).</span><span class="n">mkdir</span><span class="p">(</span><span class="n">parents</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">exist_ok</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">joblib</span><span class="p">.</span><span class="n">dump</span><span class="p">(</span><span class="n">pipeline</span><span class="p">,</span> <span class="n">Path</span><span class="p">(</span><span class="n">__file__</span><span class="p">).</span><span class="n">parent</span> <span class="o">/</span> <span class="s">'data'</span> <span class="o">/</span> <span class="s">'model.pkl'</span><span class="p">)</span>
    
    <span class="k">print</span><span class="p">(</span><span class="s">"Pipeline trained and saved to model.pkl"</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">predict</span><span class="p">():</span>
    <span class="c1"># Load the trained model
</span>    <span class="n">model</span> <span class="o">=</span> <span class="n">joblib</span><span class="p">.</span><span class="n">load</span><span class="p">(</span><span class="n">Path</span><span class="p">(</span><span class="n">__file__</span><span class="p">).</span><span class="n">parent</span> <span class="o">/</span> <span class="s">'data'</span> <span class="o">/</span> <span class="s">'model.pkl'</span><span class="p">)</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"Model loaded from model.pkl"</span><span class="p">)</span>

    <span class="c1"># Make a prediction
</span>    <span class="n">X_new</span> <span class="o">=</span> <span class="p">[[</span><span class="mf">6.1</span><span class="p">,</span> <span class="mf">2.8</span><span class="p">,</span> <span class="mf">4.7</span><span class="p">,</span> <span class="mf">1.2</span><span class="p">]]</span>
    <span class="n">y_new</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="n">predict</span><span class="p">(</span><span class="n">X_new</span><span class="p">)</span>
</code></pre></div></div>

<p>We pin the model dependencies and the Python version (pickling should be backward compatible):</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[project]</span>
<span class="py">name</span> <span class="p">=</span> <span class="s">"model"</span>
<span class="py">version</span> <span class="p">=</span> <span class="s">"0.1.0"</span>
<span class="py">description</span> <span class="p">=</span> <span class="s">"Add your description here"</span>
<span class="py">readme</span> <span class="p">=</span> <span class="s">"README.md"</span>
<span class="py">authors</span> <span class="p">=</span> <span class="p">[</span>
    <span class="err">{</span> <span class="py">name</span> <span class="p">=</span> <span class="s">"Lukas Valatka"</span><span class="p">,</span> <span class="py">email</span> <span class="p">=</span> <span class="s">"&lt;email&gt;"</span> <span class="err">}</span>
<span class="p">]</span>
<span class="py">requires-python</span> <span class="p">=</span> <span class="py">"&gt;</span><span class="p">=</span><span class="mf">3.9</span><span class="s">"</span><span class="err">
</span><span class="py">dependencies</span> <span class="p">=</span> <span class="p">[</span>
    <span class="py">"pandas</span> <span class="p">=</span><span class="err">=</span> <span class="mf">2.2</span><span class="err">.</span><span class="mi">3</span><span class="s">",</span><span class="err">
</span>    <span class="py">"scikit-learn</span> <span class="p">=</span><span class="err">=</span> <span class="mf">1.6</span><span class="err">.</span><span class="mi">1</span><span class="s">",</span><span class="err">
</span><span class="p">]</span>

<span class="nn">[build-system]</span>
<span class="py">requires</span> <span class="p">=</span> <span class="nn">["hatchling"]</span>
<span class="py">build-backend</span> <span class="p">=</span> <span class="s">"hatchling.build"</span>
</code></pre></div></div>

<p>Train the model:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>uv run python <span class="nt">-c</span> <span class="s2">"from model.main import train; train()"</span>
</code></pre></div></div>

<p>A <code class="language-plaintext highlighter-rouge">/data</code> folder should appear within <code class="language-plaintext highlighter-rouge">src/model</code>, containing the trained model. This is where the magic happens—we <strong>bundle the trained model together with the source code</strong>. (Yes, it’s a bit of an abomination, but bear with me.)</p>

<p>Register the model:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>uv build <span class="c"># this step should be part of a training pipeline</span>
<span class="nv">$ </span><span class="nb">mkdir</span> ../registry/model
<span class="nv">$ </span><span class="nb">mv </span>dist/<span class="k">*</span> ../registry/model
</code></pre></div></div>

<p>Make our fake model registry go live:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span><span class="nb">cd</span> ../registry
<span class="nv">$ </span>python <span class="nt">-m</span> http.server 8000 <span class="c"># package index is just /package folders served</span>
</code></pre></div></div>

<p>that’s it - we should be able to pull-in our trained model and predict:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>uv run <span class="nt">--with</span> <span class="nv">model</span><span class="o">==</span>0.1.0 <span class="nt">--index-url</span> http://127.0.0.1:8000 <span class="nt">--extra-index-url</span> <span class="s1">'https://pypi.python.org/simple'</span> <span class="nt">--index-strategy</span> unsafe-best-match python <span class="nt">-c</span> <span class="s2">"from model.main import predict; predict()"</span>
</code></pre></div></div>

<p>we can also add our model to a serving application, as follows:</p>

<p><code class="language-plaintext highlighter-rouge">pyproject.toml</code></p>
<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[project]</span>
<span class="py">name</span> <span class="p">=</span> <span class="s">"serving-app"</span>
<span class="py">version</span> <span class="p">=</span> <span class="s">"0.1.0"</span>
<span class="py">description</span> <span class="p">=</span> <span class="s">"Add your description here"</span>
<span class="py">readme</span> <span class="p">=</span> <span class="s">"README.md"</span>
<span class="py">authors</span> <span class="p">=</span> <span class="p">[</span>
    <span class="err">{</span> <span class="py">name</span> <span class="p">=</span> <span class="s">"Lukas Valatka"</span><span class="p">,</span> <span class="py">email</span> <span class="p">=</span> <span class="s">"&lt;email&gt;"</span> <span class="err">}</span>
<span class="p">]</span>
<span class="py">requires-python</span> <span class="p">=</span> <span class="py">"&gt;</span><span class="p">=</span><span class="mf">3.9</span><span class="s">"</span><span class="err">
</span><span class="py">dependencies</span> <span class="p">=</span> <span class="p">[</span>
    <span class="py">"model</span> <span class="p">=</span><span class="err">=</span> <span class="mf">0.1</span><span class="err">.</span><span class="mi">0</span><span class="s">",</span><span class="err">
</span>    <span class="s">"fastapi[standard]"</span><span class="p">,</span>
    <span class="s">"pandas"</span><span class="p">,</span>
    <span class="s">"pydantic"</span><span class="p">,</span>
<span class="p">]</span>

<span class="nn">[project.scripts]</span>
<span class="py">serving-app</span> <span class="p">=</span> <span class="s">"serving_app:main"</span>

<span class="nn">[build-system]</span>
<span class="py">requires</span> <span class="p">=</span> <span class="nn">["hatchling"]</span>
<span class="py">build-backend</span> <span class="p">=</span> <span class="s">"hatchling.build"</span>

<span class="nn">[[tool.uv.index]]</span>
<span class="py">name</span> <span class="p">=</span> <span class="s">"local"</span>
<span class="py">url</span> <span class="p">=</span> <span class="s">"http://localhost:8000"</span>
</code></pre></div></div>

<p>all dependencies such as scikit-learn and pandas are at exact versions they were used for training, for full reproducibility. Also reflected within the lockfile - for reproducible builds!</p>

<p>If you try to create the venv within an incorrect Python environment e.g. Python 3.8, it will complain:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>uv run <span class="nt">--python</span> 3.8 <span class="nt">--with</span> <span class="nv">model</span><span class="o">==</span>0.1.0 <span class="nt">--index-url</span> http://127.0.0.1:8000 <span class="nt">--extra-index-url</span> <span class="s1">'https://pypi.python.org/simple'</span> <span class="nt">--index-strategy</span> unsafe-best-match python
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  × No solution found when resolving <span class="sb">`</span><span class="nt">--with</span><span class="sb">`</span> dependencies:
  ╰─▶ Because the current Python version <span class="o">(</span>3.8.20<span class="o">)</span> does not satisfy Python&gt;<span class="o">=</span>3.9 and <span class="nv">pandas</span><span class="o">==</span>2.2.3 depends on Python&gt;<span class="o">=</span>3.9, we can conclude that <span class="nv">pandas</span><span class="o">==</span>2.2.3 cannot be used.
      And because <span class="nv">model</span><span class="o">==</span>0.1.0 depends on <span class="nv">pandas</span><span class="o">==</span>2.2.3 and you require <span class="nv">model</span><span class="o">==</span>0.1.0, we can conclude that your requirements are unsatisfiable.
</code></pre></div></div>

<p>So, how does this all compare to the all mighty mlflow model format:</p>

<ul>
  <li>With MLflow, you need to explicitly list model dependencies in your serving-app OR have some script automating this (pull in model, uv add deps). It’s not a native functionality.</li>
  <li>How do you make sure Python version is compatible between training and serving? Need to manually check this.</li>
</ul>

<p>Just a thought experiment. I still believe MLflow is the go-to format due to its flavors, and storing large files within your Nexus will make SREs frown big time.</p>

<p>But who knows? Maybe in the future, MLflow might just consider disguising models as standard Python packages—achieving both framework flexibility via its flavors and interoperability with the Python ecosystem.</p>

<p>EDIT 2025-02-19 - it appears <a href="https://simonwillison.net/2025/Feb/7/pip-install-llm-smollm2/">Simon Willison himself put an LLM in package</a>. Same way, pip install the package+model and run with uvx. So maybe the wheel format is not that of a bad idea after all? 🤔 Kudos to my colleague <a href="https://www.linkedin.com/in/lodenachtergaele/">Lode</a> for sharing this.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Inventing a model package format. Spoiler alert: MLFlow is here to stay.]]></summary></entry></feed>