<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: sqlite</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/sqlite.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2026-07-14T19:44:11+00:00</updated><author><name>Simon Willison</name></author><entry><title>lobste.rs is now running on SQLite</title><link href="https://simonwillison.net/2026/Jul/14/lobsters-sqlite/#atom-tag" rel="alternate"/><published>2026-07-14T19:44:11+00:00</published><updated>2026-07-14T19:44:11+00:00</updated><id>https://simonwillison.net/2026/Jul/14/lobsters-sqlite/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://lobste.rs/s/ko1ji1/lobste_rs_is_now_running_on_sqlite"&gt;lobste.rs is now running on SQLite&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Community site &lt;a href="https://lobste.rs"&gt;Lobsters&lt;/a&gt; has been planning a migration away from MariaDB &lt;a href="https://github.com/lobsters/lobsters/issues/539#issuecomment-4959857588"&gt;since August 2018&lt;/a&gt; - originally targeting PostgreSQL, but last year they decided to &lt;a href="https://github.com/lobsters/lobsters/issues/539#issuecomment-2964114295"&gt;investigate SQLite&lt;/a&gt; instead.&lt;/p&gt;
&lt;p&gt;This weekend they completed the migration, and now consider it stable enough that it looks like this is the permanent architecture for the site going forward:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;SQLite seems to have passed with flying colors: cpu usage is down, memory usage is down, site seems to be snappier at least for me, 1/2 the vps cost once mariadb vps is taken down&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The Lobsters Rails application now runs on a single VPS, with a primary content SQLite database file that's around 3.8GB. &lt;a href="https://lobste.rs/s/ko1ji1/lobste_rs_is_now_running_on_sqlite#c_c9ydhs"&gt;There's also&lt;/a&gt; a 1.1GB cache database, a 218MB queue database, and a still growing 555MB rack_attack database used by the &lt;a href="https://github.com/rack/rack-attack"&gt;Rack::Attack&lt;/a&gt; middleware for blocking and throttling abusive requests.&lt;/p&gt;
&lt;p&gt;There are plenty more details in both the linked thread and this &lt;a href="https://github.com/lobsters/lobsters/pull/1927"&gt;SQLite migration PR&lt;/a&gt; by Thomas Dziedzic, which added 735 lines and removed 593 lines across 30 commits and 188 files. That PR built on top of previous PRs &lt;a href="https://github.com/lobsters/lobsters/pull/1705"&gt;#1705&lt;/a&gt;, &lt;a href="https://github.com/lobsters/lobsters/pull/1871"&gt;#1871&lt;/a&gt;, and &lt;a href="https://github.com/lobsters/lobsters/pull/1924"&gt;#1924&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This is a really useful case study, and a great reminder that you can get a whole lot done with a single server and SQLite in 2026.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/migrations"&gt;migrations&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ops"&gt;ops&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/rails"&gt;rails&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/lobsters"&gt;lobsters&lt;/a&gt;&lt;/p&gt;



</summary><category term="migrations"/><category term="ops"/><category term="rails"/><category term="sqlite"/><category term="lobsters"/></entry><entry><title>DOOMQL</title><link href="https://simonwillison.net/2026/Jul/13/doomql/#atom-tag" rel="alternate"/><published>2026-07-13T22:34:41+00:00</published><updated>2026-07-13T22:34:41+00:00</updated><id>https://simonwillison.net/2026/Jul/13/doomql/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/petergpt/doomql"&gt;DOOMQL&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Peter Gostev built this using GPT-5.6 Sol. This is a &lt;em&gt;lot&lt;/em&gt; of fun: &lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;DOOMQL started with a deliberately unreasonable question: what if SQLite were the game engine, not merely the place where a game stores data?&lt;/p&gt;
&lt;p&gt;The result is a small, original Doom-like game in which SQL owns movement, collision, enemies, combat, progression and every RGB pixel on screen.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It's implemented as a Python terminal script - I tried it out like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /tmp
git clone https://github.com/petergpt/doomql
cd doomql
uv run host/doomql.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img alt="Screenshot of a macOS terminal window titled &amp;quot;doomql — python3.14 ◂ uv run host/doomql.py — 134×31&amp;quot; showing a retro Doom-style game rendered as text-mode pixel art. The scene is a pixelated first-person corridor with gray paneled walls, dark red doors on the far left and right, a floating cyan-and-gold coin pickup on the right side, a white crosshair near the center, and a dark weapon barrel rising from the bottom center. A status bar below the scene reads &amp;quot;HP 100/100 AMMO 037 SCORE 00225 INDEX MISSING TICK 0028450&amp;quot;, followed by an orange line &amp;quot;FIND THE INDEX TOKEN&amp;quot; and a cyan controls line &amp;quot;WASD MOVE J/L OR ARROWS TURN SPACE FIRE E USE P PAUSE CTRL-C EXIT&amp;quot;." src="https://static.simonwillison.net/static/2026/doomql-window.png" /&gt;&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/petergpt/doomql/blob/main/sql/003_render.sql"&gt;the huge SQL query&lt;/a&gt; that implements a full ray tracer in SQLite using a recursive CTE.&lt;/p&gt;
&lt;p&gt;Running the above script creates a &lt;code&gt;/tmp/doomql/.doomql/doomql.sqlite&lt;/code&gt; SQLite database, which you can explore using Datasette like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx --prerelease=allow  --with datasette-apps datasette \
  /tmp/doomql/.doomql/doomql.sqlite \
  -p 4444 --root --secret 1 --internal internal.db
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--with datasette-apps&lt;/code&gt; option installs the new &lt;a href="https://simonwillison.net/2026/Jun/18/datasette-apps/"&gt;Datasette Apps&lt;/a&gt; plugin, which supports creating custom HTML+JavaScript apps that can run SQL queries directly within the Datasette interface.&lt;/p&gt;
&lt;p&gt;I created a new app, pasted the copy-paste prompt into Claude chat (Fable 5) &lt;a href="https://claude.ai/share/c793280c-2ef1-4555-a7c2-31281abfdf78"&gt;and told it&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Build an app that displays the current state of the screen using the frame_pixels view with its x, y, r, g, b columns. have it refresh once a second.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This got me a working HTML+JavaScript app inside Datasette that could reflect the current state while I played the game in my terminal. Then I added:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;add a minimap&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And now my Datasette App looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a dark-themed web app running a retro Doom-style game rendered from SQL queries. The page header reads &amp;quot;DOOMQL&amp;quot; with buttons &amp;quot;All apps&amp;quot;, &amp;quot;Edit app&amp;quot;, &amp;quot;Pin&amp;quot;, and &amp;quot;Full screen&amp;quot;. Inside the game panel, the title &amp;quot;DOOMQL&amp;quot; sits above the subtitle &amp;quot;auto-refreshing once a second · frame and tactical map straight from SQL&amp;quot;. The left side shows a pixelated first-person corridor view with gray walls, dark red doors, a floating cyan-and-gold coin pickup, a white crosshair, and a weapon barrel at bottom center. A status bar below reads &amp;quot;HP 100/100 AMMO 037 SCORE 00225 INDEX MISSING TICK 0027847&amp;quot;. On the right, a panel titled &amp;quot;TACTICAL MAP&amp;quot; shows a top-down grid map with a player triangle, a red enemy circle, yellow pickup dots, red wall markers, and a green exit square, with a legend reading &amp;quot;you&amp;quot;, &amp;quot;enemy&amp;quot;, &amp;quot;pickup&amp;quot;, &amp;quot;locked door&amp;quot;, &amp;quot;door&amp;quot;, &amp;quot;exit&amp;quot;. Below the game view, an orange banner reads &amp;quot;FIND THE INDEX TOKEN&amp;quot;, followed by the cyan line &amp;quot;READ-ONLY VIEWER · SELECT x, y, r, g, b FROM frame_pixels&amp;quot;. At the bottom, a green &amp;quot;RUNNING&amp;quot; badge appears beside the stats &amp;quot;160×54 · 8,640 pixels · 3 hostiles · query 89 ms · refreshing every 1 s&amp;quot;." src="https://static.simonwillison.net/static/2026/doomql-datasette-app.png" /&gt;&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/7c78184476fccd4b70b02f7f9048dffa"&gt;the HTML app code&lt;/a&gt; - paste that into your own Datasette instance (using the &lt;code&gt;uvx --with datasette-apps&lt;/code&gt; recipe from above) to try it yourself.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/petergostev/status/2076692164310884468"&gt;@petergostev&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/games"&gt;games&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sql"&gt;sql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gpt"&gt;gpt&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-apps"&gt;datasette-apps&lt;/a&gt;&lt;/p&gt;



</summary><category term="games"/><category term="sql"/><category term="sqlite"/><category term="ai"/><category term="datasette"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="gpt"/><category term="datasette-apps"/></entry><entry><title>sqlite-utils 4.1.1</title><link href="https://simonwillison.net/2026/Jul/12/sqlite-utils/#atom-tag" rel="alternate"/><published>2026-07-12T20:55:30+00:00</published><updated>2026-07-12T20:55:30+00:00</updated><id>https://simonwillison.net/2026/Jul/12/sqlite-utils/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/sqlite-utils/releases/tag/4.1.1"&gt;sqlite-utils 4.1.1&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;Mainly a fix for an edge case that regular Claude chat spotted while &lt;a href="https://claude.ai/share/564b187d-d126-47ea-9b59-07c16ade0b70"&gt;experimenting with the 4.1 release&lt;/a&gt; to answer a question about ON DELETE.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;table.transform()&lt;/code&gt; now raises a &lt;code&gt;TransactionError&lt;/code&gt; if called while a transaction is open with &lt;code&gt;PRAGMA foreign_keys&lt;/code&gt; enabled and the table is referenced by foreign keys with destructive &lt;code&gt;ON DELETE&lt;/code&gt; actions - &lt;code&gt;CASCADE&lt;/code&gt;, &lt;code&gt;SET NULL&lt;/code&gt; or &lt;code&gt;SET DEFAULT&lt;/code&gt;. The pragma cannot be changed inside a transaction, so previously dropping the old table as part of the transform could fire those actions and silently delete or modify referencing rows. See &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#python-api-transform-foreign-keys-transactions"&gt;Foreign keys and transactions&lt;/a&gt; for details and workarounds. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/794"&gt;#794&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://sqlite-utils.datasette.io/en/stable/cli.html"&gt;CLI&lt;/a&gt; and &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html"&gt;Python API&lt;/a&gt; documentation now cross-reference each other: CLI sections link to the equivalent Python API functionality and Python API sections link back to the corresponding CLI command. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/791"&gt;#791&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="sqlite"/><category term="sqlite-utils"/></entry><entry><title>sqlite-utils 4.1</title><link href="https://simonwillison.net/2026/Jul/11/sqlite-utils/#atom-tag" rel="alternate"/><published>2026-07-11T23:50:20+00:00</published><updated>2026-07-11T23:50:20+00:00</updated><id>https://simonwillison.net/2026/Jul/11/sqlite-utils/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/sqlite-utils/releases/tag/4.1"&gt;sqlite-utils 4.1&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;The first dot-release since &lt;a href="https://simonwillison.net/2026/Jul/7/sqlite-utils-4/"&gt;4.0 a few days ago&lt;/a&gt;, introducing a number of minor new features.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;sqlite-utils insert&lt;/code&gt; and &lt;code&gt;sqlite-utils upsert&lt;/code&gt; now accept a &lt;code&gt;--code&lt;/code&gt; option for &lt;a href="https://sqlite-utils.datasette.io/en/stable/cli.html#cli-insert-code"&gt;providing a block of Python code&lt;/a&gt; (or a path to a &lt;code&gt;.py&lt;/code&gt; file) that defines a &lt;code&gt;rows()&lt;/code&gt; function or &lt;code&gt;rows&lt;/code&gt; iterable of rows to insert, as an alternative to importing from a file. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/684"&gt;#684&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;code&gt;sqlite-utils&lt;/code&gt; already had features that allow you to pass blocks of Python code as CLI arguments, for example &lt;a href="https://sqlite-utils.datasette.io/en/stable/cli.html#converting-data-in-columns"&gt;this one&lt;/a&gt; for the &lt;code&gt;sqlite-utils convert&lt;/code&gt; command:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;sqlite&lt;/span&gt;&lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-s1"&gt;utils&lt;/span&gt; &lt;span class="pl-s1"&gt;convert&lt;/span&gt; &lt;span class="pl-s1"&gt;content&lt;/span&gt;.&lt;span class="pl-c1"&gt;db&lt;/span&gt; &lt;span class="pl-s1"&gt;articles&lt;/span&gt; &lt;span class="pl-s1"&gt;headline&lt;/span&gt; '
&lt;span class="pl-s1"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;convert&lt;/span&gt;(&lt;span class="pl-s1"&gt;value&lt;/span&gt;):
    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;value&lt;/span&gt;.&lt;span class="pl-c1"&gt;upper&lt;/span&gt;()'&lt;/pre&gt;
&lt;p&gt;Allowing blocks of code to &lt;a href="https://sqlite-utils.datasette.io/en/stable/cli.html#inserting-rows-generated-by-python-code"&gt;generate new rows directly&lt;/a&gt; was on obvious extension of that pattern:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;sqlite&lt;/span&gt;&lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-s1"&gt;utils&lt;/span&gt; &lt;span class="pl-s1"&gt;insert&lt;/span&gt; &lt;span class="pl-s1"&gt;data&lt;/span&gt;.&lt;span class="pl-c1"&gt;db&lt;/span&gt; &lt;span class="pl-s1"&gt;creatures&lt;/span&gt; &lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-s1"&gt;code&lt;/span&gt; '
&lt;span class="pl-s1"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;rows&lt;/span&gt;():
    &lt;span class="pl-k"&gt;yield&lt;/span&gt; {&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;1&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Cleo"&lt;/span&gt;}
    &lt;span class="pl-k"&gt;yield&lt;/span&gt; {&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;2&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Suna"&lt;/span&gt;}
' &lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-s1"&gt;pk&lt;/span&gt; &lt;span class="pl-s1"&gt;id&lt;/span&gt;&lt;/pre&gt;

&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;sqlite-utils insert&lt;/code&gt; and &lt;code&gt;sqlite-utils upsert&lt;/code&gt; now accept &lt;code&gt;--type column-name type&lt;/code&gt; to &lt;a href="https://sqlite-utils.datasette.io/en/stable/cli.html#cli-insert-csv-tsv-column-types"&gt;override the type automatically chosen when the table is created&lt;/a&gt;. This is useful for CSV or TSV columns such as ZIP codes that look like integers but should be stored as &lt;code&gt;TEXT&lt;/code&gt; to preserve leading zeros. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/131"&gt;#131&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;A long-standing feature request which turned out to be a &lt;a href="https://github.com/SAY-5/sqlite-utils/commit/d2ac3765ed9f0516bb0cbc2508a5c3907fb6a71a"&gt;simple implementation&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;code&gt;table.drop_index(name)&lt;/code&gt; method and &lt;code&gt;sqlite-utils drop-index&lt;/code&gt; command for dropping an index by name. Both accept &lt;code&gt;ignore=True&lt;/code&gt;/&lt;code&gt;--ignore&lt;/code&gt; to ignore a missing index. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/626"&gt;#626&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite-utils query&lt;/code&gt; can now read the SQL query from standard input by passing &lt;code&gt;-&lt;/code&gt; in place of the query, for example &lt;code&gt;echo "select * from dogs" | sqlite-utils query dogs.db -&lt;/code&gt;. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/765"&gt;#765&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Two more small features. I had Codex review all open issues and highlight the easiest ones!&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;sqlite-utils upsert&lt;/code&gt; can now infer the primary key of an existing table, so &lt;code&gt;--pk&lt;/code&gt; can be omitted when upserting into a table that already has a primary key.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Another Codex suggestion, an obvious missing CLI feature from a Python library improvement that shipped in the 4.0 release.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;table.transform()&lt;/code&gt; and &lt;code&gt;table.transform_sql()&lt;/code&gt; now accept &lt;code&gt;strict=True&lt;/code&gt; or &lt;code&gt;strict=False&lt;/code&gt; to change a table’s &lt;a href="https://www.sqlite.org/stricttables.html"&gt;SQLite strict mode&lt;/a&gt;. Omitting the option preserves the existing mode. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/787"&gt;#787&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;sqlite-utils transform&lt;/code&gt; command now accepts &lt;code&gt;--strict&lt;/code&gt; and &lt;code&gt;--no-strict&lt;/code&gt; to change a table’s strict mode. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/787"&gt;#787&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;These two were inspired by &lt;a href="https://evanhahn.com/prefer-strict-tables-in-sqlite/"&gt;Prefer STRICT tables in SQLite&lt;/a&gt; by Evan Hahn, which did the rounds &lt;a href="https://news.ycombinator.com/item?id=48873940"&gt;on Hacker News&lt;/a&gt; today. Evan pointed out that:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Unfortunately, I don’t think there’s a way to ALTER a table to make it strict. I think you have to copy the data out of the non-strict table into the strict one.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's exactly what the &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#transforming-a-table"&gt;sqlite-utils transform mechanism&lt;/a&gt; does, so I extended it to add the ability to switch tables from strict to non-strict and vice-versa.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/ab8256b81646ad967a601975e206de64"&gt;the GPT-5.6 Sol xhigh Codex transcript&lt;/a&gt; I used to implement those new strict table features. One of the most useful prompts I ran was this one:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;use uv run python -c and manually exercise the new .transform(strict=) option, see if you can find any edge-cases or bugs&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Effectively telling the model to manually test its work, outside of the automated tests it had already written. This turned up two minor issues that we then fixed.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="python"/><category term="sqlite"/><category term="sqlite-utils"/><category term="annotated-release-notes"/><category term="ai-assisted-programming"/></entry><entry><title>sqlite-utils 4.0, now with database schema migrations</title><link href="https://simonwillison.net/2026/Jul/7/sqlite-utils-4/#atom-tag" rel="alternate"/><published>2026-07-07T19:32:57+00:00</published><updated>2026-07-07T19:32:57+00:00</updated><id>https://simonwillison.net/2026/Jul/7/sqlite-utils-4/#atom-tag</id><summary type="html">
    &lt;p&gt;This morning I released &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0"&gt;sqlite-utils 4.0&lt;/a&gt;, the 124th release of that project and the first major version bump since &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v3-0"&gt;3.0&lt;/a&gt; in November 2020. In addition to some small but significant breaking changes (described in &lt;a href="https://sqlite-utils.datasette.io/en/stable/upgrading.html"&gt;this upgrade guide&lt;/a&gt;), this version introduces three major features: &lt;strong&gt;database migrations&lt;/strong&gt;, &lt;strong&gt;nested transactions&lt;/strong&gt; (via a new &lt;code&gt;db.atomic()&lt;/code&gt; method), and support for &lt;strong&gt;compound foreign keys&lt;/strong&gt;.&lt;/p&gt;
&lt;h4 id="database-schema-migrations-using-sqlite-utils"&gt;Database schema migrations using sqlite-utils&lt;/h4&gt;
&lt;p&gt;Schema migrations define a sequence of changes to be made to a SQLite database, plus a mechanism for tracking which migrations have been applied and applying any that are found to be pending.&lt;/p&gt;
&lt;p&gt;Migrations are defined in Python files using the &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html"&gt;sqlite-utils Python library&lt;/a&gt;, which includes a powerful &lt;code&gt;table.transform()&lt;/code&gt; method providing &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#transforming-a-table"&gt;enhanced alter table capabilities&lt;/a&gt; that are not supported by SQLite's &lt;code&gt;ALTER TABLE&lt;/code&gt; statement.&lt;/p&gt;
&lt;p&gt;(&lt;code&gt;table.transform()&lt;/code&gt; implements the pattern &lt;a href="https://www.sqlite.org/lang_altertable.html#otheralter"&gt;recommended by the SQLite documentation&lt;/a&gt; - create a new temporary table with the new schema, copy across the data, then drop the old table and rename the temporary one in its place.)&lt;/p&gt;
&lt;p&gt;Here's an example migration file which creates a table called &lt;code&gt;creatures&lt;/code&gt;, adds an additional column to it in a second step, then changes the types of two of the columns in a third:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;sqlite_utils&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Migrations&lt;/span&gt;

&lt;span class="pl-s1"&gt;migrations&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;Migrations&lt;/span&gt;(&lt;span class="pl-s"&gt;"creatures"&lt;/span&gt;)

&lt;span class="pl-en"&gt;@&lt;span class="pl-en"&gt;migrations&lt;/span&gt;()&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;create_table&lt;/span&gt;(&lt;span class="pl-s1"&gt;db&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"creatures"&lt;/span&gt;].&lt;span class="pl-c1"&gt;create&lt;/span&gt;(
        {&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-s1"&gt;int&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s1"&gt;str&lt;/span&gt;, &lt;span class="pl-s"&gt;"species"&lt;/span&gt;: &lt;span class="pl-s1"&gt;str&lt;/span&gt;},
        &lt;span class="pl-s1"&gt;pk&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"id"&lt;/span&gt;,
    )

&lt;span class="pl-en"&gt;@&lt;span class="pl-en"&gt;migrations&lt;/span&gt;()&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;add_weight&lt;/span&gt;(&lt;span class="pl-s1"&gt;db&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"creatures"&lt;/span&gt;].&lt;span class="pl-c1"&gt;add_column&lt;/span&gt;(&lt;span class="pl-s"&gt;"weight"&lt;/span&gt;, &lt;span class="pl-s1"&gt;float&lt;/span&gt;)

&lt;span class="pl-en"&gt;@&lt;span class="pl-en"&gt;migrations&lt;/span&gt;()&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;change_column_types&lt;/span&gt;(&lt;span class="pl-s1"&gt;db&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"creatures"&lt;/span&gt;].&lt;span class="pl-c1"&gt;transform&lt;/span&gt;(&lt;span class="pl-s1"&gt;types&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;{&lt;span class="pl-s"&gt;"species"&lt;/span&gt;: &lt;span class="pl-s1"&gt;int&lt;/span&gt;, &lt;span class="pl-s"&gt;"weight"&lt;/span&gt;: &lt;span class="pl-s1"&gt;str&lt;/span&gt;})&lt;/pre&gt;
&lt;p&gt;Save that as &lt;code&gt;migrations.py&lt;/code&gt; and run it against a fresh database like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx sqlite-utils migrate data.db migrations.py&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then if you check the schema of that database:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx sqlite-utils schema data.db&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;You'll see this SQL:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;CREATE&lt;/span&gt; &lt;span class="pl-k"&gt;TABLE&lt;/span&gt; "&lt;span class="pl-en"&gt;_sqlite_migrations&lt;/span&gt;" (
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;id&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt; &lt;span class="pl-k"&gt;PRIMARY KEY&lt;/span&gt;,
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;migration_set&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;,
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;,
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;applied_at&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;
);
&lt;span class="pl-k"&gt;CREATE&lt;/span&gt; &lt;span class="pl-k"&gt;UNIQUE INDEX&lt;/span&gt; "&lt;span class="pl-en"&gt;idx__sqlite_migrations_migration_set_name&lt;/span&gt;"
    &lt;span class="pl-k"&gt;ON&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;_sqlite_migrations&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; (&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;migration_set&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;);
&lt;span class="pl-k"&gt;CREATE&lt;/span&gt; &lt;span class="pl-k"&gt;TABLE&lt;/span&gt; "&lt;span class="pl-en"&gt;creatures&lt;/span&gt;" (
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;id&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt; &lt;span class="pl-k"&gt;PRIMARY KEY&lt;/span&gt;,
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;,
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;species&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt;,
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;weight&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;
);&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;_sqlite_migrations&lt;/code&gt; table is used to keep track of which migration functions have been run. The &lt;code&gt;creatures&lt;/code&gt; table above is the schema after all three migrations have been applied.&lt;/p&gt;
&lt;p&gt;To see a list of migrations, both pending and applied, run this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx sqlite-utils migrate data.db migrations.py --list&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Migrations for: creatures

  Applied:
    create_table - 2026-07-07 17:58:41.360051+00:00
    add_weight - 2026-07-07 17:58:41.360608+00:00
    change_column_types - 2026-07-07 18:01:15.802000+00:00

  Pending:
    (none)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you don't specify a migrations file, the &lt;code&gt;sqlite-utils migrate data.db&lt;/code&gt; command will scan the current directory and its subdirectories for files called &lt;code&gt;migrations.py&lt;/code&gt; and apply any &lt;code&gt;Migrations()&lt;/code&gt; instances it finds in them.&lt;/p&gt;
&lt;p&gt;You can also execute migrations &lt;a href="https://sqlite-utils.datasette.io/en/stable/migrations.html#applying-migrations-in-python"&gt;from Python code&lt;/a&gt; using the &lt;code&gt;migrations.apply(db)&lt;/code&gt; method, which is useful for building tools that manage their own database schemas over multiple versions. My own &lt;a href="https://llm.datasette.io/"&gt;LLM tool&lt;/a&gt; has been using a version of this pattern for several years now, as shown in &lt;a href="https://github.com/simonw/llm/blob/0.31/llm/embeddings_migrations.py"&gt;llm/embeddings_migrations.py&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="prior-art"&gt;Prior art&lt;/h4&gt;
&lt;p&gt;My favorite implementation of this pattern remains &lt;a href="https://docs.djangoproject.com/en/6.0/topics/migrations/"&gt;Django's Migrations&lt;/a&gt;, developed by Andrew Godwin based on his earlier project &lt;a href="https://github.com/andrewgodwin/south"&gt;South&lt;/a&gt;. Fun fact: Andrew, Russ Keith-Magee, and I presented our competing approaches to schema migrations for Django on the &lt;a href="https://www.youtube.com/watch?v=VSq8m00p1FM"&gt;Schema Evolution panel&lt;/a&gt; at the very first DjangoCon back in 2008! My attempt was called &lt;a href="https://simonwillison.net/2008/Sep/3/dmigrations/"&gt;dmigrations&lt;/a&gt;, developed with a team at Global Radio in London.&lt;/p&gt;
&lt;p&gt;Django's migrations can be automatically generated from model definitions and include the ability to roll back to a previous version. The &lt;code&gt;sqlite-utils&lt;/code&gt; approach is deliberately simpler: unlike Django, &lt;code&gt;sqlite-utils&lt;/code&gt; encourages programmatic table creation rather than a model definition ORM, so there isn't anything we can use to automatically generate migrations.&lt;/p&gt;
&lt;p&gt;I decided to skip rollback, since in my experience it's a feature that is rarely used. With a SQLite project, an easy way to achieve rollback is to create a copy of your database file before you apply the migrations!&lt;/p&gt;
&lt;h4 id="migrating-from-sqlite-migrate"&gt;Migrating from sqlite-migrate&lt;/h4&gt;
&lt;p&gt;The design of &lt;code&gt;sqlite-utils&lt;/code&gt; migrations is three years old now - I had originally released it as a separate package called &lt;a href="https://github.com/simonw/sqlite-migrate"&gt;sqlite-migrate&lt;/a&gt;, which never quite graduated beyond a beta release.&lt;/p&gt;
&lt;p&gt;I've used that package in enough places now that I'm confident in the design, so I've decided to promote it to a feature of &lt;code&gt;sqlite-utils&lt;/code&gt; to make it available by default to all of the other tools in the growing sqlite-utils/Datasette/LLM ecosystem.&lt;/p&gt;
&lt;p&gt;I made &lt;a href="https://github.com/simonw/sqlite-migrate/releases/tag/0.2"&gt;one last release&lt;/a&gt; of &lt;code&gt;sqlite-migrate&lt;/code&gt;, which switches it to depend on &lt;code&gt;sqlite-utils&amp;gt;=4&lt;/code&gt; and replaces the &lt;code&gt;__init__.py&lt;/code&gt; file with the following:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;sqlite_utils&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Migrations&lt;/span&gt;

&lt;span class="pl-s1"&gt;__all__&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; [&lt;span class="pl-s"&gt;"Migrations"&lt;/span&gt;]&lt;/pre&gt;
&lt;p&gt;Any existing project that depends on &lt;code&gt;sqlite-migrate&lt;/code&gt; should continue to work without alterations.&lt;/p&gt;
&lt;h4 id="everything-else-in-sqlite-utils-4-0"&gt;Everything else in sqlite-utils 4.0&lt;/h4&gt;
&lt;p&gt;Here are the release notes for this version, with some inline annotations:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The 4.0 release includes some minor backwards-incompatible fixes (hence the major version number bump) and introduces three major new features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sqlite-utils.datasette.io/en/stable/migrations.html#migrations"&gt;Database migrations&lt;/a&gt;, providing a structured mechanism for evolving a project’s schema over time. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/752"&gt;#752&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I think of migrations as the signature new feature, hence this blog post.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#python-api-atomic"&gt;Nested transaction support&lt;/a&gt; via &lt;code&gt;db.atomic()&lt;/code&gt;, plus numerous improvements to how transactions work across the library. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/755"&gt;#755&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;code&gt;sqlite-utils&lt;/code&gt; has long had a confused relationship with database transactions, partly because when I started designing the library back in 2018 I didn't yet have a great feel for how those worked in SQLite itself.&lt;/p&gt;
&lt;p&gt;Adding migrations to the core library made me determined to finally crack this nut, since transactions make migration systems a whole lot safer and easier to reason about.&lt;/p&gt;
&lt;p&gt;I ended up building this around a &lt;code&gt;db.atomic()&lt;/code&gt; context manager which looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;atomic&lt;/span&gt;():
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;table&lt;/span&gt;(&lt;span class="pl-s"&gt;"dogs"&lt;/span&gt;).&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;1&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Cleo"&lt;/span&gt;}, &lt;span class="pl-s1"&gt;pk&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"id"&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;table&lt;/span&gt;(&lt;span class="pl-s"&gt;"dogs"&lt;/span&gt;).&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;2&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Pancakes"&lt;/span&gt;})&lt;/pre&gt;
&lt;p&gt;SQLite supports &lt;a href="https://sqlite.org/lang_savepoint.html"&gt;Savepoints&lt;/a&gt;, and as a result &lt;code&gt;db.atomic()&lt;/code&gt; can be nested to carry out transactions inside of transactions. It's pretty neat!&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Support for &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#python-api-compound-foreign-keys"&gt;compound foreign keys&lt;/a&gt;, including creation, transformation and introspection through &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#python-api-introspection-foreign-keys"&gt;table.foreign_keys&lt;/a&gt;. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/594"&gt;#594&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This came about when I asked a coding agent to review all open issues and PRs for things that should be included in a 4.0 release since they would represent breaking changes if I added them later, and it correctly identified that compound foreign keys were exactly that kind of feature.&lt;/p&gt;
&lt;p&gt;I started with a breaking change to the &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#python-api-introspection-foreign-keys"&gt;table.foreign_keys&lt;/a&gt; introspection method, and then decided to see if Claude Fable 5 could handle the more fiddly job of integrating compound foreign key &lt;em&gt;creation&lt;/em&gt; into the library. The API design it helped create felt &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#compound-foreign-keys"&gt;exactly right to me&lt;/a&gt; - consistent with how the rest of the library worked already.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Other notable changes include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Upserts now use SQLite’s &lt;code&gt;INSERT ... ON CONFLICT ... DO UPDATE SET&lt;/code&gt; syntax, detect existing table primary keys automatically and reject records that are missing required primary key values. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/652"&gt;#652&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This was the change that first pushed me to consider a breaking-change 4.0 version bump. I built this to help support &lt;a href="https://github.com/simonw/sqlite-chronicle"&gt;sqlite-chronicle&lt;/a&gt;, which uses triggers to keep track of rows in a table that have been inserted, updated or deleted.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;db.query()&lt;/code&gt; now executes immediately and rejects statements that do not return rows; use &lt;code&gt;db.execute()&lt;/code&gt; for writes and DDL.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Probably the &lt;a href="https://sqlite-utils.datasette.io/en/stable/upgrading.html#python-api-changes"&gt;most disruptive breaking change&lt;/a&gt; - I've had to update a few places in my own code to switch from &lt;code&gt;db.query()&lt;/code&gt; to &lt;code&gt;db.execute()&lt;/code&gt; as a result.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;CSV and TSV imports now detect column types by default, while inserts into existing tables preserve those tables’ column types. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/679"&gt;#679&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;code&gt;sqlite-utils insert data.db creatures creatures.csv --detect-types&lt;/code&gt; flag was a later addition to allow column types (text, integer, real) to be automatically detected based on the data in a CSV. It should be the default, and releasing a 4.0 means I can make it so.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;table.extract()&lt;/code&gt; and &lt;code&gt;extracts=&lt;/code&gt; no longer create lookup table records for all-&lt;code&gt;null&lt;/code&gt; values. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/186"&gt;#186&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The oldest issue addressed by this release - the underlying bug was opened (by me) in October 2020.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;See &lt;a href="https://sqlite-utils.datasette.io/en/stable/upgrading.html#upgrading-3-to-4"&gt;Upgrading from 3.x to 4.0&lt;/a&gt; for details on backwards-incompatible changes.&lt;/p&gt;
&lt;p&gt;The detailed release notes for the features and fixes shipped during the 4.0 pre-release cycle are available in &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0a0"&gt;4.0a0&lt;/a&gt;, &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0a1"&gt;4.0a1&lt;/a&gt;, &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0rc1"&gt;4.0rc1&lt;/a&gt;, &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0rc2"&gt;4.0rc2&lt;/a&gt;, &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0rc3"&gt;4.0rc3&lt;/a&gt; and &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0rc4"&gt;4.0rc4&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The upgrade guide was entirely written by Claude Fable 5, Claude Opus 4.8 and GPT-5.5. The same is true of the release notes.&lt;/p&gt;
&lt;p&gt;This is the kind of documentation I've slowly become comfortable outsourcing to the robots. It doesn't need to convince people of anything, or express any opinions - its job is to be as accurate and detailed as possible. I've reviewed the release notes closely and can confirm they are accurate and comprehensive.&lt;/p&gt;
&lt;h4 id="claude-fable-5-helped-a-lot"&gt;Claude Fable 5 helped a lot&lt;/h4&gt;
&lt;p&gt;I released the first alpha of sqlite-utils 4.0 &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#a0-2025-05-08"&gt;over a year ago&lt;/a&gt;. I've been dragging my heels on the stable release because of the amount of work it would take to track down and clean up the many other minor design flaws that a major version number allowed me to take on.&lt;/p&gt;
&lt;p&gt;Assistance from Claude Fable 5 (and to a lesser extent Opus 4.8 and GPT-5.5) gave me just the boost I needed to overcome inertia and make the most of the time I could afford to spend on this library.&lt;/p&gt;
&lt;p&gt;Fable has &lt;em&gt;really good taste&lt;/em&gt; in API design, and is &lt;a href="https://simonwillison.net/2026/Jun/11/fable-is-relentlessly-proactive/"&gt;relentlessly proactive&lt;/a&gt; if you give it a more open goal. My most successful prompt was a review task that I issued against what I thought was the last release candidate:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;review the changes on main since the last tagged 3.x release - I am about to ship them as sqlite-utils 4.0, a stable version that promises no backwards-incompatible fixes for a very long time.&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;review the changelog and upgrade guide, and write yourself scratch scripts to try out all of the new features in v4 - save those scripts but don't commit them&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I tried this with GPT-5.5 xhigh in Codex Desktop and Fable 5 in Claude Code.&lt;/p&gt;
&lt;p&gt;GPT-5.5 &lt;a href="https://gist.github.com/simonw/823fdecc031371d56dce39537adc0096"&gt;wrote 5 Python scripts&lt;/a&gt; and didn't turn up anything particularly interesting - its &lt;a href="https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4899982463"&gt;final report is here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Fable 5 &lt;a href="https://gist.github.com/simonw/95800bf584f8e437f1cf0d48d9ef81e6"&gt;wrote 12 scripts&lt;/a&gt;, identified 4 release blockers and 10 additional issues &lt;a href="https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4900034150"&gt;in its report&lt;/a&gt;, and built a neat &lt;a href="https://gist.githubusercontent.com/simonw/95800bf584f8e437f1cf0d48d9ef81e6/raw/c43918b36a129bba1d2f2a129117aa11c85146c0/12_bug_repros.py"&gt;combined repro script&lt;/a&gt;, which, when run, output the following:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;=== 1. Failed db.execute() write leaves an implicit transaction open ===
  in_transaction after failed write: True
  BUG: table 'other' silently lost when connection closed

=== 2. Leading ';' bypasses the query() first-token scanner ===
  BUG: raised OperationalError: no such savepoint: sqlite_utils_query
  BUG: row persisted despite rollback (count=1)

=== 3. Rejected write PRAGMA via query() still takes effect ===
  BUG: user_version=5 after 'rejected' statement (docs say no effect)

=== 4. Implicit compound FK resolves pk columns in table order, not PK order ===
  BUG: other_columns reported as ('b', 'a'), should be ('a', 'b')
  BUG: transform of valid data raised IntegrityError: FOREIGN KEY constraint failed

=== 5. ForeignKey (now a dataclass) is no longer hashable ===
  BUG: cannot use 'sqlite_utils.db.ForeignKey' as a set element (unhashable type: 'ForeignKey')

=== 6. Mixed ForeignKey objects and tuples in foreign_keys= rejected ===
  BUG: foreign_keys= should be a list of tuples

=== 7. insert --csv into an EXISTING table transforms its column types ===
  BUG: existing zip '01234' is now 1234 (column type: int)

=== 8. insert(pk=, alter=True) regression: InvalidColumns before alter runs ===
  BUG: InvalidColumns: Invalid primary key column ['id'] for table t with columns ['a']

=== 9. migrate --stop-before an already-applied migration applies everything ===
  BUG: m2 was applied despite --stop-before m1 (m1 already applied)

=== 10. ensure_autocommit_on() silently commits an open transaction ===
  BUG: row survived rollback (count=1) - transaction was committed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I found myself agreeing with almost all of them. Here's &lt;a href="https://github.com/simonw/sqlite-utils/pull/779"&gt;the PR with 16 commits&lt;/a&gt; where we worked through them in turn.&lt;/p&gt;
&lt;p&gt;There's no doubt in my mind that sqlite-utils 4.0 is a significantly higher-quality release than if I had built it without the assistance of the latest frontier models.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/schema-migrations"&gt;schema-migrations&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/anthropic"&gt;anthropic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-mythos-fable"&gt;claude-mythos-fable&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="schema-migrations"/><category term="projects"/><category term="sqlite"/><category term="ai"/><category term="sqlite-utils"/><category term="annotated-release-notes"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="anthropic"/><category term="claude"/><category term="agentic-engineering"/><category term="claude-mythos-fable"/></entry><entry><title>sqlite-utils 4.0rc3</title><link href="https://simonwillison.net/2026/Jul/6/sqlite-utils/#atom-tag" rel="alternate"/><published>2026-07-06T05:40:08+00:00</published><updated>2026-07-06T05:40:08+00:00</updated><id>https://simonwillison.net/2026/Jul/6/sqlite-utils/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/sqlite-utils/releases/tag/4.0rc3"&gt;sqlite-utils 4.0rc3&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;I hoped to release &lt;code&gt;sqlite-utils 4.0&lt;/code&gt; stable this weekend, but as I worked through the backlog of issues and PRs with a combination of Claude Fable 5 and GPT-5.5 the changelog since rc2 &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#rc3-2026-07-05"&gt;kept getting bigger&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The biggest new feature is support for introspecting and creating compound foreign keys - a feature that involves a subtle breaking change to &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#foreign-keys"&gt;table.foreign_keys&lt;/a&gt; and hence needed to land for the 4.0 stable release.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sqlite-utils&lt;/code&gt; also now follows SQLite's convention for case insensitive column names, which turned out to touch &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#case-insensitive-column-matching"&gt;a bunch of different places at once&lt;/a&gt;.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gpt"&gt;gpt&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-mythos-fable"&gt;claude-mythos-fable&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="sqlite"/><category term="sqlite-utils"/><category term="annotated-release-notes"/><category term="gpt"/><category term="claude-mythos-fable"/></entry><entry><title>sqlite-utils 4.0rc2, mostly written by Claude Fable (for about $149.25)</title><link href="https://simonwillison.net/2026/Jul/5/sqlite-utils-fable/#atom-tag" rel="alternate"/><published>2026-07-05T01:00:48+00:00</published><updated>2026-07-05T01:00:48+00:00</updated><id>https://simonwillison.net/2026/Jul/5/sqlite-utils-fable/#atom-tag</id><summary type="html">
    &lt;p&gt;I wrote about the &lt;a href="https://simonwillison.net/2026/Jun/21/sqlite-utils-40rc1/"&gt;sqlite-utils 4.0rc1&lt;/a&gt; release a couple of weeks ago. Since we only have Claude Fable on our Max subscriptions for a few more days, I decided to see if it could help me get to a 4.0 stable release that I felt truly comfortable about, since I try to keep to &lt;a href="https://semver.org"&gt;SemVer&lt;/a&gt; and like my incompatible major versions to be as rare as possible.&lt;/p&gt;
&lt;p&gt;I started with this prompt, in Claude Code for web on my iPhone:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Final review before shipping a stable 4.0 release - very important to spot any last minute things that would be a breaking change if we fix them later&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/simonw/sqlite-utils/blob/0c369a447eeaf39084f0d14a45b3eeb7eacb631b/fable-review-4.0rc1.md"&gt;that initial report&lt;/a&gt; it created for me. There were some &lt;em&gt;significant&lt;/em&gt; problems that I hadn't myself encountered yet - 5 that Fable categorized as "release blockers". Here's the worst of the bunch:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;delete_where()&lt;/code&gt; never commits and poisons the connection (data loss)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Table.delete_where()&lt;/code&gt; (&lt;code&gt;sqlite_utils/db.py:2948&lt;/code&gt;) runs its DELETE via a bare &lt;code&gt;self.db.execute()&lt;/code&gt; with no &lt;code&gt;atomic()&lt;/code&gt; wrapper — compare &lt;code&gt;Table.delete()&lt;/code&gt; at &lt;code&gt;db.py:2944&lt;/code&gt;, which wraps correctly. The connection is left &lt;code&gt;in_transaction=True&lt;/code&gt;, so every &lt;em&gt;subsequent&lt;/em&gt; &lt;code&gt;atomic()&lt;/code&gt; call takes the savepoint branch (&lt;code&gt;db.py:430-440&lt;/code&gt;) and never commits either.&lt;/p&gt;
&lt;p&gt;Reproduced end-to-end:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;sqlite_utils&lt;/span&gt;.&lt;span class="pl-c1"&gt;Database&lt;/span&gt;(&lt;span class="pl-s"&gt;"dw.db"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"t"&lt;/span&gt;].&lt;span class="pl-c1"&gt;insert_all&lt;/span&gt;([{&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-s1"&gt;i&lt;/span&gt;} &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;i&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-en"&gt;range&lt;/span&gt;(&lt;span class="pl-c1"&gt;3&lt;/span&gt;)], &lt;span class="pl-s1"&gt;pk&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"id"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"t"&lt;/span&gt;].&lt;span class="pl-c1"&gt;delete_where&lt;/span&gt;(&lt;span class="pl-s"&gt;"id = ?"&lt;/span&gt;, [&lt;span class="pl-c1"&gt;0&lt;/span&gt;])   &lt;span class="pl-c"&gt;# conn.in_transaction is now True&lt;/span&gt;
&lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"t"&lt;/span&gt;].&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;50&lt;/span&gt;})
&lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"u"&lt;/span&gt;].&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"a"&lt;/span&gt;: &lt;span class="pl-c1"&gt;1&lt;/span&gt;})
&lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;close&lt;/span&gt;()
&lt;span class="pl-c"&gt;# Reopen: rows are [0, 1, 2] — the delete, row 50, AND table u are all gone.&lt;/span&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's a really bad bug! Very glad I didn't ship that, although at least it would have been a bug I could fix in a 4.0.1 point release, not a design flaw that would force a 5.0.&lt;/p&gt;
&lt;p&gt;Over the course of 37 prompts, 34 commits and +1,321 -190 code changes over 30 separate files, we worked through the entire set of feedback in turn, making several other design improvements along the way.&lt;/p&gt;
&lt;p&gt;A weird thing about coding agents is that harder tasks like this one actually provide &lt;em&gt;more&lt;/em&gt; opportunity to do other things at the same time, since the agent sometimes needs 10-15 minutes to churn away on a new task. I went out to enjoy the Half Moon Bay 4th of July parade, occasionally checking in and prompting the next step for Fable from my phone.&lt;/p&gt;
&lt;p&gt;Full details &lt;a href="https://github.com/simonw/sqlite-utils/pull/767"&gt;in the PR&lt;/a&gt; and &lt;a href="https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd"&gt;this shared transcript&lt;/a&gt;. I switched to my laptop for the final review, which I conducted through GitHub's PR interface.&lt;/p&gt;
&lt;p&gt;The most significant changes relate to transaction handling, which was the signature new feature in &lt;a href="https://simonwillison.net/2026/Jun/21/sqlite-utils-40rc1/#new-feature-db-atomic-transactions"&gt;the earlier RC&lt;/a&gt;. The new RC now includes &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#transactions-and-saving-your-changes"&gt;comprehensive documentation&lt;/a&gt; on the new transaction model, the intro to which I'll quote here in full:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Every method in this library that writes to the database - &lt;code&gt;insert()&lt;/code&gt;, &lt;code&gt;upsert()&lt;/code&gt;, &lt;code&gt;update()&lt;/code&gt;, &lt;code&gt;delete()&lt;/code&gt;, &lt;code&gt;delete_where()&lt;/code&gt;, &lt;code&gt;transform()&lt;/code&gt;, &lt;code&gt;create_table()&lt;/code&gt;, &lt;code&gt;create_index()&lt;/code&gt;, &lt;code&gt;enable_fts()&lt;/code&gt; and the rest - runs inside its own transaction and commits it before returning. Your changes are saved to disk as soon as the method call finishes:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;Database&lt;/span&gt;(&lt;span class="pl-s"&gt;"data.db"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;table&lt;/span&gt;(&lt;span class="pl-s"&gt;"news"&lt;/span&gt;).&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"headline"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Dog wins award"&lt;/span&gt;})
&lt;span class="pl-c"&gt;# The new row is already saved - no commit() required&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;The same applies to raw SQL executed with &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-transactions-execute"&gt;db.execute()&lt;/a&gt; - a write statement is committed as soon as it has run.&lt;/p&gt;
&lt;p&gt;You never need to call &lt;code&gt;commit()&lt;/code&gt;, and you do not need to close the database to persist your changes. There are exactly two situations where you need to think about transactions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;You want to group several write operations together, so they either all succeed or all fail - use &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-atomic"&gt;db.atomic()&lt;/a&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;You are &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-transactions-manual"&gt;managing a transaction yourself&lt;/a&gt; with &lt;code&gt;db.begin()&lt;/code&gt;, in which case nothing is committed until you commit - the library will never commit a transaction you opened.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;p&gt;In reviewing Fable's documentation - I find that reviewing the documentation edits first is an &lt;em&gt;excellent&lt;/em&gt; way to build an initial understanding of what has changed - I spotted &lt;a href="https://github.com/simonw/sqlite-utils/blob/6c88067ab76b9597fb1c538c53164632526a2891/docs/python-api.rst?plain=1#L386"&gt;this detail&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;db.atomic()&lt;/code&gt; and the automatic per-method transactions are designed for connections in Python's default transaction handling mode. Connections created with the Python 3.12+ &lt;code&gt;sqlite3.connect(..., autocommit=True)&lt;/code&gt; or &lt;code&gt;autocommit=False&lt;/code&gt; options are not supported, because &lt;code&gt;commit()&lt;/code&gt; and &lt;code&gt;rollback()&lt;/code&gt; behave differently on those connections.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I admit I hadn't thought about how &lt;code&gt;sqlite-utils&lt;/code&gt; would react to the more recent &lt;a href="https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.autocommit"&gt;autocommit setting&lt;/a&gt;, added in Python 3.12. It turns out "behave differently on those connections" equated to almost the entire test suite failing, so I worked with the model to &lt;a href="https://github.com/simonw/sqlite-utils/commit/f7ff3e2027aefb9905ebb2e611e5bbb0a62382c5"&gt;ensure that this difference&lt;/a&gt; would not break how the library works.&lt;/p&gt;
&lt;h4 id="and-a-final-review-by-gpt-5-5"&gt;And a final review by GPT-5.5&lt;/h4&gt;
&lt;p&gt;I used to think that the idea of having one model review the work of another was somewhat absurd - it felt weirdly superstitious. The problem is &lt;em&gt;it really does work&lt;/em&gt; - I've started habitually having Anthropic's best model review OpenAI's work and vice versa, because I've had that turn up interesting results often enough to be valuable.&lt;/p&gt;
&lt;p&gt;I prompted Codex Desktop and GPT-5.5 xhigh with the following:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Review changes since the last RC. Also confirm that the changelog is up-to-date.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Which was enough to turn up two issues worth investigating:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Findings&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[P1] &lt;a href="https://github.com/simonw/sqlite-utils/blob/04f8971546418962aaf6579d4028c7117d6c3a20/sqlite_utils/db.py#L663"&gt;sqlite_utils/db.py:663&lt;/a&gt; &lt;code&gt;db.query()&lt;/code&gt; now rejects non-row statements only after calling &lt;code&gt;db.execute()&lt;/code&gt;, and &lt;a href="https://github.com/simonw/sqlite-utils/blob/04f8971546418962aaf6579d4028c7117d6c3a20/sqlite_utils/db.py#L705"&gt;sqlite_utils/db.py:705&lt;/a&gt; auto-commits those writes first. So &lt;code&gt;db.query("update ...")&lt;/code&gt; raises &lt;code&gt;ValueError&lt;/code&gt; but the update is already committed. That is a surprising side effect for a method documented as “can only be used with SQL that returns rows.”&lt;/li&gt;
&lt;li&gt;[P1] &lt;a href="https://github.com/simonw/sqlite-utils/blob/04f8971546418962aaf6579d4028c7117d6c3a20/sqlite_utils/db.py#L672"&gt;sqlite_utils/db.py:672&lt;/a&gt; &lt;code&gt;INSERT ... RETURNING&lt;/code&gt; through &lt;code&gt;db.query()&lt;/code&gt; only commits after the returned generator is fully exhausted. &lt;code&gt;db.query("insert ... returning ...")&lt;/code&gt; without iteration, or common &lt;code&gt;next(db.query(...))&lt;/code&gt; usage, leaves the transaction open and the write can be rolled back on close. This contradicts &lt;a href="https://github.com/simonw/sqlite-utils/blob/04f8971546418962aaf6579d4028c7117d6c3a20/docs/changelog.rst?plain=1#L15"&gt;docs/changelog.rst:15&lt;/a&gt; and &lt;a href="https://github.com/simonw/sqlite-utils/blob/04f8971546418962aaf6579d4028c7117d6c3a20/docs/python-api.rst?plain=1#L232"&gt;docs/python-api.rst:232&lt;/a&gt;, which say it takes effect without iteration.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I pasted that into a fresh Fable session, which ran some experiments to confirm the problem:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Both findings were confirmed. &lt;code&gt;db.query()&lt;/code&gt; called &lt;code&gt;self.execute()&lt;/code&gt; first, which auto-commits writes, and only then checked &lt;code&gt;cursor.description&lt;/code&gt; — so &lt;code&gt;db.query("update ...")&lt;/code&gt; committed the update before raising &lt;code&gt;ValueError&lt;/code&gt;. And the &lt;code&gt;INSERT ... RETURNING&lt;/code&gt; commit lived at the end of the returned generator, so it never fired unless you exhausted the iterator — &lt;code&gt;next(db.query(...))&lt;/code&gt; or an un-iterated call left the transaction open, contradicting what the changelog and docs promise.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/simonw/sqlite-utils/pull/768"&gt;the PR&lt;/a&gt; with the fix, and the &lt;a href="https://claude.ai/code/session_012U3iRfJoTZ5vd22cBSF2nJ"&gt;full Claude Code transcript&lt;/a&gt;. Reviewing this code helped me build a better mental model of the edge cases of SQLite transaction semantics!&lt;/p&gt;
&lt;h4 id="for-an-estimated-unsubsidized-cost-of-149-25"&gt;For an estimated (unsubsidized) cost of $149.25&lt;/h4&gt;
&lt;p&gt;I upgraded to the Claude Max $200/month plan (I was previously on $100/month) to increase my Fable allowance for the remaining time until &lt;a href="https://www.anthropic.com/news/redeploying-fable-5"&gt;the July 7th Fablepocalypse&lt;/a&gt;, when even Claude Max subscribers will have to pay full API cost for the model.&lt;/p&gt;
&lt;p&gt;I was curious as to how much this would have cost me if I had been paying those costs directly. At first I thought those numbers weren't available to me since I had run the work remotely using Claude Code for web, and then I realized I could run &lt;a href="https://www.agentsview.io"&gt;AgentsView&lt;/a&gt; inside that existing session to get that cost estimate!&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Run "uvx agentsview --help"​ and then use that tool to calculate the cost of this session&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Claude figured out how to use the &lt;code&gt;session list --include-children&lt;/code&gt; command and came out with the following:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Transcript&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Main session&lt;/td&gt;
&lt;td&gt;claude-fable-5&lt;/td&gt;
&lt;td&gt;$141.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API-surface sweep agent&lt;/td&gt;
&lt;td&gt;claude-fable-5&lt;/td&gt;
&lt;td&gt;$2.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transactions/atomic review agent&lt;/td&gt;
&lt;td&gt;claude-fable-5&lt;/td&gt;
&lt;td&gt;$2.39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Post-rc1 commits review agent&lt;/td&gt;
&lt;td&gt;claude-fable-5&lt;/td&gt;
&lt;td&gt;$1.72&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrations review agent&lt;/td&gt;
&lt;td&gt;claude-fable-5&lt;/td&gt;
&lt;td&gt;$1.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt-counting agent&lt;/td&gt;
&lt;td&gt;claude-opus-4-8&lt;/td&gt;
&lt;td&gt;$0.32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$149.25&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;I'm very glad I'm on that subscription! I really should have &lt;a href="https://simonwillison.net/2026/Jul/3/judgement/"&gt;followed my own advice&lt;/a&gt; and leaned more heavily into subagents with cheaper models.&lt;/p&gt;
&lt;p&gt;Here's what &lt;a href="https://claude.ai/settings/usage"&gt;claude.ai/settings/usage&lt;/a&gt; is showing me right now:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/fable-plan-usage.webp" alt="Screenshot of a Claude plan usage limits panel: &amp;quot;Plan usage limits Max (20x)&amp;quot;; &amp;quot;Current session&amp;quot; with &amp;quot;Resets in 3 hr 52 min&amp;quot; showing a progress bar at &amp;quot;7% used&amp;quot;; &amp;quot;Weekly limits&amp;quot; heading with a &amp;quot;Learn more about usage limits&amp;quot; link; &amp;quot;All models&amp;quot; with &amp;quot;Resets Wed 12:00 PM&amp;quot; showing a progress bar at &amp;quot;32% used&amp;quot;; &amp;quot;Fable&amp;quot; with &amp;quot;Resets Wed 12:00 PM&amp;quot; showing a progress bar at &amp;quot;63% used&amp;quot;." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;I have several other major Fable-driven projects on the go right now as well, with the goal of hitting 100% on that Fable bar just in time for the price increase.&lt;/p&gt;
&lt;h4 id="the-full-release-notes-for-sqlite-utils-4-0rc2"&gt;The full release notes for sqlite-utils 4.0rc2&lt;/h4&gt;
&lt;p&gt;Here are &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#rc2-2026-07-04"&gt;the full release notes&lt;/a&gt; for the RC. I had Fable add these to an "Unreleased" section of the changelog as each change landed, reviewing them as it went. This has the neat side effect that &lt;a href="https://github.com/simonw/sqlite-utils/commits/4.0rc2/docs/changelog.rst"&gt;the commit history of the changelog&lt;/a&gt; acts as a concise summary of each of the changes that went into the release.&lt;/p&gt;
&lt;p&gt;In the past I've had a policy of writing release notes by hand, but honestly these are better than I would have created myself. Release notes are a great example of writing that I'm OK to outsource to agents because they need to be boring, predictable and accurate.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Breaking changes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Write statements executed with &lt;code&gt;db.execute()&lt;/code&gt; are now committed automatically, unless a transaction is already open in which case they join it. Previously they opened an implicit transaction that stayed open until something committed it - writes appeared to work when read on the same connection but were silently rolled back when the connection closed. Code that relied on rolling back uncommitted &lt;code&gt;db.execute()&lt;/code&gt; writes should use the new &lt;code&gt;db.begin()&lt;/code&gt; method to open an explicit transaction first. The transaction model is documented in full at &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-transactions"&gt;Transactions and saving your changes&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;db.query()&lt;/code&gt; now executes its SQL as soon as it is called, rather than waiting until the returned generator is first iterated. Rows are still fetched lazily during iteration. SQL errors are now raised at the call site, statements such as &lt;code&gt;INSERT ... RETURNING&lt;/code&gt; are executed and committed immediately without needing to iterate over their results, and passing a statement that returns no rows - previously a silent no-op - now raises a &lt;code&gt;ValueError&lt;/code&gt; recommending &lt;code&gt;db.execute()&lt;/code&gt; instead. A statement rejected this way is rolled back before the error is raised, so it has no effect on the database.&lt;/li&gt;
&lt;li&gt;Python API validation errors now raise &lt;code&gt;ValueError&lt;/code&gt; instead of &lt;code&gt;AssertionError&lt;/code&gt;. Previously invalid arguments - such as &lt;code&gt;create_table()&lt;/code&gt; with no columns, &lt;code&gt;transform()&lt;/code&gt; on a table that does not exist, or passing both &lt;code&gt;ignore=True&lt;/code&gt; and &lt;code&gt;replace=True&lt;/code&gt; - were rejected using bare &lt;code&gt;assert&lt;/code&gt; statements, which are silently skipped when Python runs with the &lt;code&gt;-O&lt;/code&gt; flag. Code that caught &lt;code&gt;AssertionError&lt;/code&gt; for these cases should catch &lt;code&gt;ValueError&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;table.upsert()&lt;/code&gt; and &lt;code&gt;table.upsert_all()&lt;/code&gt; now raise &lt;code&gt;PrimaryKeyRequired&lt;/code&gt; if a record is missing a value for any primary key column, or has a value of &lt;code&gt;None&lt;/code&gt; for one. Previously such records - which can never match an existing row - were quietly inserted as brand new rows, or triggered a confusing &lt;code&gt;KeyError&lt;/code&gt; after the insert had already taken place.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;db.enable_wal()&lt;/code&gt; and &lt;code&gt;db.disable_wal()&lt;/code&gt; now raise a &lt;code&gt;sqlite_utils.db.TransactionError&lt;/code&gt; if called while a transaction is open. Previously they would silently commit the open transaction as a side effect of changing the journal mode, breaking the rollback guarantee of &lt;code&gt;db.atomic()&lt;/code&gt; and of user-managed transactions.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;View&lt;/code&gt; class no longer has an &lt;code&gt;enable_fts()&lt;/code&gt; method. It existed only to raise &lt;code&gt;NotImplementedError&lt;/code&gt;, since full-text search is not supported for views - calling it now raises &lt;code&gt;AttributeError&lt;/code&gt; instead, and the method no longer appears in the API reference. The &lt;code&gt;sqlite-utils enable-fts&lt;/code&gt; command shows a clean error when pointed at a view.&lt;/li&gt;
&lt;li&gt;The no-op &lt;code&gt;-d/--detect-types&lt;/code&gt; flag has been removed from the &lt;code&gt;insert&lt;/code&gt; and &lt;code&gt;upsert&lt;/code&gt; commands. Type detection has been the default for CSV/TSV data since 4.0a1, so the flag did nothing - invocations using it should simply drop it. &lt;code&gt;--no-detect-types&lt;/code&gt; remains available to disable detection.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Database()&lt;/code&gt; now raises a &lt;code&gt;sqlite_utils.db.TransactionError&lt;/code&gt; if passed a connection created with the Python 3.12+ &lt;code&gt;sqlite3.connect(..., autocommit=True)&lt;/code&gt; or &lt;code&gt;autocommit=False&lt;/code&gt; options. &lt;code&gt;commit()&lt;/code&gt; and &lt;code&gt;rollback()&lt;/code&gt; behave differently on those connections, which previously caused every write made by the library to be silently discarded when the connection closed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Everything else:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Fixed a bug where &lt;code&gt;table.delete_where()&lt;/code&gt;, &lt;code&gt;table.optimize()&lt;/code&gt; and &lt;code&gt;table.rebuild_fts()&lt;/code&gt; did not commit their changes, leaving the connection inside an open transaction. Their work - and any subsequent writes - could then be silently rolled back when the connection was closed. All three now use &lt;code&gt;db.atomic()&lt;/code&gt;, consistent with the other write methods.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;sqlite-utils drop-table&lt;/code&gt; command now refuses to drop a view, and &lt;code&gt;drop-view&lt;/code&gt; refuses to drop a table. Previously each would silently drop the wrong type of object if the name matched. Both now exit with an error suggesting the correct command to use.&lt;/li&gt;
&lt;li&gt;Migrations applied by the new &lt;a href="https://sqlite-utils.datasette.io/en/latest/migrations.html#migrations"&gt;migrations system&lt;/a&gt; now run inside a transaction, together with the record of the migration having been applied. If a migration raises an exception its changes are rolled back and it stays pending, so it can be safely re-applied after the error is fixed. Migrations that cannot run inside a transaction, such as those executing &lt;code&gt;VACUUM&lt;/code&gt;, can opt out using &lt;code&gt;@migrations(transactional=False)&lt;/code&gt; - see &lt;a href="https://sqlite-utils.datasette.io/en/latest/migrations.html#migrations-transactions"&gt;Migrations and transactions&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;table.upsert()&lt;/code&gt; and &lt;code&gt;table.upsert_all()&lt;/code&gt; now detect the primary key or compound primary key of an existing table, so the &lt;code&gt;pk=&lt;/code&gt; argument is no longer required when upserting into a table that already has a primary key.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;db.table(table_name).insert({})&lt;/code&gt; can now be used to insert a row consisting entirely of default values into an existing table, using &lt;code&gt;INSERT INTO ... DEFAULT VALUES&lt;/code&gt;. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/759"&gt;#759&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Improvements to the &lt;code&gt;sqlite-utils migrate&lt;/code&gt; command: &lt;code&gt;--stop-before&lt;/code&gt; values that do not match any known migration are now an error instead of being silently ignored, &lt;code&gt;--stop-before&lt;/code&gt; now works correctly with migration files that still use the older &lt;code&gt;sqlite_migrate.Migrations&lt;/code&gt; class, and &lt;code&gt;--list&lt;/code&gt; is now a read-only operation that no longer creates the database file or the migrations tracking table. &lt;code&gt;migrations.applied()&lt;/code&gt; now returns migrations in the order they were applied.&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;db.begin()&lt;/code&gt;, &lt;code&gt;db.commit()&lt;/code&gt; and &lt;code&gt;db.rollback()&lt;/code&gt; methods for taking manual control of transactions, as an alternative to the &lt;code&gt;db.atomic()&lt;/code&gt; context manager.&lt;/li&gt;
&lt;li&gt;New documentation: &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-transactions"&gt;Transactions and saving your changes&lt;/a&gt; describes how transactions work and when changes are committed, and a new &lt;a href="https://sqlite-utils.datasette.io/en/latest/upgrading.html#upgrading"&gt;Upgrading&lt;/a&gt; page details the changes needed to move between major versions.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/anthropic"&gt;anthropic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm-pricing"&gt;llm-pricing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gpt"&gt;gpt&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-mythos-fable"&gt;claude-mythos-fable&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="sqlite"/><category term="sqlite-utils"/><category term="annotated-release-notes"/><category term="anthropic"/><category term="claude"/><category term="llm-pricing"/><category term="coding-agents"/><category term="claude-code"/><category term="agentic-engineering"/><category term="gpt"/><category term="claude-mythos-fable"/></entry><entry><title>sqlite-utils 4.0rc1 adds migrations and nested transactions</title><link href="https://simonwillison.net/2026/Jun/21/sqlite-utils-40rc1/#atom-tag" rel="alternate"/><published>2026-06-21T23:35:47+00:00</published><updated>2026-06-21T23:35:47+00:00</updated><id>https://simonwillison.net/2026/Jun/21/sqlite-utils-40rc1/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;a href="https://sqlite-utils.datasette.io/en/latest/"&gt;sqlite-utils&lt;/a&gt; is my combined Python library and CLI tool for working with SQLite databases. It provides an extensive set of higher-level operations on top of Python's default &lt;a href="https://docs.python.org/3/library/sqlite3.html"&gt;sqlite3 package&lt;/a&gt;, including support for &lt;a href="https://sqlite-utils.datasette.io/en/latest/cli.html#transforming-tables"&gt;complex table transformations&lt;/a&gt;, automatic table creation &lt;a href="https://sqlite-utils.datasette.io/en/latest/cli.html#inserting-json-data"&gt;from JSON data&lt;/a&gt; and a whole lot more.&lt;/p&gt;
&lt;p&gt;I released &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#rc1-2026-06-21"&gt;sqlite-utils 4.0rc1&lt;/a&gt;, the first release candidate for sqlite-utils v4. The major version bump indicates some (minor) backwards incompatible changes, so I'm interested in having people try this out before I commit to a stable release.&lt;/p&gt;
&lt;h4 id="new-feature-migrations"&gt;New feature: migrations&lt;/h4&gt;
&lt;p&gt;There are two significant new features in this RC compared to the previous 4.0 alphas.&lt;/p&gt;
&lt;p&gt;The first is support for &lt;strong&gt;database migrations&lt;/strong&gt;. This isn't a completely new implementation - it's a slightly modified port of the &lt;a href="https://github.com/simonw/sqlite-migrate"&gt;sqlite-migrate&lt;/a&gt; package I released a few years ago. I think that package has proved itself over time, so I'm now ready to bundle it with &lt;code&gt;sqlite-utils&lt;/code&gt; directly.&lt;/p&gt;
&lt;p&gt;Here's what a set of migrations in a &lt;code&gt;migrations.py&lt;/code&gt; file looks like:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;sqlite_utils&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Database&lt;/span&gt;, &lt;span class="pl-v"&gt;Migrations&lt;/span&gt;

&lt;span class="pl-s1"&gt;migrations&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;Migrations&lt;/span&gt;(&lt;span class="pl-s"&gt;"creatures"&lt;/span&gt;)

&lt;span class="pl-en"&gt;@&lt;span class="pl-en"&gt;migrations&lt;/span&gt;()&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;create_table&lt;/span&gt;(&lt;span class="pl-s1"&gt;db&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"creatures"&lt;/span&gt;].&lt;span class="pl-c1"&gt;create&lt;/span&gt;(
        {&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-s1"&gt;int&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s1"&gt;str&lt;/span&gt;, &lt;span class="pl-s"&gt;"species"&lt;/span&gt;: &lt;span class="pl-s1"&gt;str&lt;/span&gt;},
        &lt;span class="pl-s1"&gt;pk&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"id"&lt;/span&gt;,
    )

&lt;span class="pl-en"&gt;@&lt;span class="pl-en"&gt;migrations&lt;/span&gt;()&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;add_weight&lt;/span&gt;(&lt;span class="pl-s1"&gt;db&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"creatures"&lt;/span&gt;].&lt;span class="pl-c1"&gt;add_column&lt;/span&gt;(&lt;span class="pl-s"&gt;"weight"&lt;/span&gt;, &lt;span class="pl-s1"&gt;float&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;This defines a set of two migrations, one creating the &lt;code&gt;creatures&lt;/code&gt; table and another adding a column to it.&lt;/p&gt;
&lt;p&gt;You can then run those migrations either using Python:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;Database&lt;/span&gt;(&lt;span class="pl-s"&gt;"creatures.db"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;migrations&lt;/span&gt;.&lt;span class="pl-c1"&gt;apply&lt;/span&gt;(&lt;span class="pl-s1"&gt;db&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;Or with the command-line &lt;code&gt;migrate&lt;/code&gt; command:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;sqlite-utils migrate creatures.db migrations.py&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The system is deliberately small: it doesn't provide reverse migrations, so any mistakes you make should be fixed by deploying a fresh migration to undo them.&lt;/p&gt;
&lt;p&gt;Its predecessor has been used by &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; and various other projects for several years, so I'm confident that the design is stable and works well.&lt;/p&gt;
&lt;p&gt;The new migrations feature &lt;a href="https://sqlite-utils.datasette.io/en/latest/migrations.html"&gt;is documented here&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="new-feature-db-atomic-transactions"&gt;New feature: db.atomic() transactions&lt;/h4&gt;
&lt;p&gt;This feature is a lot less exercised than migrations, so it deserves more attention from testers.&lt;/p&gt;
&lt;p&gt;Previously, &lt;code&gt;sqlite-utils&lt;/code&gt; mostly left transaction management up to its users, via a &lt;code&gt;with db.conn:&lt;/code&gt; construct that reused the &lt;code&gt;sqlite3&lt;/code&gt; mechanism directly.&lt;/p&gt;
&lt;p&gt;SQLite supports nested transactions in the form of savepoints, so I wanted an abstraction that could make those as easy to use as possible.&lt;/p&gt;
&lt;p&gt;I borrowed the terminology "atomic" from Django and Peewee. Here's what the new API looks like:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;atomic&lt;/span&gt;():
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;table&lt;/span&gt;(&lt;span class="pl-s"&gt;"dogs"&lt;/span&gt;).&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;1&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Cleo"&lt;/span&gt;}, &lt;span class="pl-s1"&gt;pk&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"id"&lt;/span&gt;)
    &lt;span class="pl-k"&gt;try&lt;/span&gt;:
        &lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;atomic&lt;/span&gt;():
            &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;table&lt;/span&gt;(&lt;span class="pl-s"&gt;"dogs"&lt;/span&gt;).&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;2&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Pancakes"&lt;/span&gt;})
            &lt;span class="pl-k"&gt;raise&lt;/span&gt; &lt;span class="pl-en"&gt;ValueError&lt;/span&gt;(&lt;span class="pl-s"&gt;"skip this one"&lt;/span&gt;)
    &lt;span class="pl-k"&gt;except&lt;/span&gt; &lt;span class="pl-v"&gt;ValueError&lt;/span&gt;:
        &lt;span class="pl-k"&gt;pass&lt;/span&gt;
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;table&lt;/span&gt;(&lt;span class="pl-s"&gt;"dogs"&lt;/span&gt;).&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;3&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Marnie"&lt;/span&gt;})&lt;/pre&gt;
&lt;p&gt;More details &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#transactions-with-db-atomic"&gt;in the documentation&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="backwards-incompatible-changes"&gt;Backwards incompatible changes&lt;/h4&gt;
&lt;p&gt;The backwards incompatible changes in v4 were described in the alpha release notes. For &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#a0-2025-05-08"&gt;4.0a0&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Upsert operations now use SQLite's &lt;code&gt;INSERT ... ON CONFLICT SET&lt;/code&gt; syntax on all SQLite versions later than 3.23.1. This is a very slight breaking change for apps that depend on the previous &lt;code&gt;INSERT OR IGNORE&lt;/code&gt; followed by &lt;code&gt;UPDATE&lt;/code&gt; behavior. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/652"&gt;#652&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Python library users can opt-in to the previous implementation by passing &lt;code&gt;use_old_upsert=True&lt;/code&gt; to the &lt;code&gt;Database()&lt;/code&gt; constructor, see &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-old-upsert"&gt;Alternative upserts using INSERT OR IGNORE&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Dropped support for Python 3.8, added support for Python 3.13. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/646"&gt;#646&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sqlite-utils tui&lt;/code&gt; is now provided by the &lt;a href="https://github.com/simonw/sqlite-utils-tui"&gt;sqlite-utils-tui&lt;/a&gt; plugin. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/648"&gt;#648&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Test suite now also runs against SQLite 3.23.1, the last version (from 2018-04-10) before the new &lt;code&gt;INSERT ... ON CONFLICT SET&lt;/code&gt; syntax was added. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/654"&gt;#654&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;And for &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#a1-2025-11-23"&gt;4.0a1&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change&lt;/strong&gt;: The &lt;code&gt;db.table(table_name)&lt;/code&gt; method now only works with tables. To access a SQL view use &lt;code&gt;db.view(view_name)&lt;/code&gt; instead. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/657"&gt;#657&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;table.insert_all()&lt;/code&gt; and &lt;code&gt;table.upsert_all()&lt;/code&gt; methods can now accept an iterator of lists or tuples as an alternative to dictionaries. The first item should be a list/tuple of column names. See &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-insert-lists"&gt;Inserting data from a list or tuple iterator&lt;/a&gt; for details. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/672"&gt;#672&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change&lt;/strong&gt;: The default floating point column type has been changed from &lt;code&gt;FLOAT&lt;/code&gt; to &lt;code&gt;REAL&lt;/code&gt;, which is the correct SQLite type for floating point values. This affects auto-detected columns when inserting data. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/645"&gt;#645&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Now uses &lt;code&gt;pyproject.toml&lt;/code&gt; in place of &lt;code&gt;setup.py&lt;/code&gt; for packaging. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/675"&gt;#675&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Tables in the Python API now do a much better job of remembering the primary key and other schema details from when they were first created. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/655"&gt;#655&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change&lt;/strong&gt;: The &lt;code&gt;table.convert()&lt;/code&gt; and &lt;code&gt;sqlite-utils convert&lt;/code&gt; mechanisms no longer skip values that evaluate to &lt;code&gt;False&lt;/code&gt;. Previously the &lt;code&gt;--skip-false&lt;/code&gt; option was needed, this has been removed. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/542"&gt;#542&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change&lt;/strong&gt;: Tables created by this library now wrap table and column names in &lt;code&gt;"double-quotes"&lt;/code&gt; in the schema. Previously they would use &lt;code&gt;[square-braces]&lt;/code&gt;. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/677"&gt;#677&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;--functions&lt;/code&gt; CLI argument now accepts a path to a Python file in addition to accepting a string full of Python code. It can also now be specified multiple times. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/659"&gt;#659&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change:&lt;/strong&gt; Type detection is now the default behavior for the &lt;code&gt;insert&lt;/code&gt; and &lt;code&gt;upsert&lt;/code&gt; CLI commands when importing CSV or TSV data. Previously all columns were treated as &lt;code&gt;TEXT&lt;/code&gt; unless the &lt;code&gt;--detect-types&lt;/code&gt; flag was passed. Use the new &lt;code&gt;--no-detect-types&lt;/code&gt; flag to restore the old behavior. The &lt;code&gt;SQLITE_UTILS_DETECT_TYPES&lt;/code&gt; environment variable has been removed. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/679"&gt;#679&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h4 id="try-it-out"&gt;Try it out&lt;/h4&gt;
&lt;p&gt;You can install the new RC like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;pip install sqlite-utils==4.0rc1&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Or try the CLI version directly with &lt;code&gt;uvx&lt;/code&gt; like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx --with sqlite-utils==4.0rc1 sqlite-utils --help&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Come chat with us about it in the &lt;a href="https://discord.gg/Ass7bCAMDw"&gt;sqlite-utils Discord channel&lt;/a&gt;, or file any bugs in &lt;a href="https://github.com/simonw/sqlite-utils/issues"&gt;GitHub Issues&lt;/a&gt;.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/schema-migrations"&gt;schema-migrations&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="schema-migrations"/><category term="projects"/><category term="sqlite"/><category term="sqlite-utils"/><category term="annotated-release-notes"/></entry><entry><title>Mapping SQLite result columns back to their source `table.column`</title><link href="https://simonwillison.net/2026/Jun/13/sqlite-column-provenance/#atom-tag" rel="alternate"/><published>2026-06-13T23:05:00+00:00</published><updated>2026-06-13T23:05:00+00:00</updated><id>https://simonwillison.net/2026/Jun/13/sqlite-column-provenance/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/sqlite-column-provenance#readme"&gt;Mapping SQLite result columns back to their source `table.column`&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;It would be neat if arbitrary SQL queries in &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; could be rendered with additional information based on which columns from which tables were included in the results.&lt;/p&gt;
&lt;p&gt;To build that, we would need to be able to look at a SQL query like &lt;code&gt;select users.name, orders.total from users join orders on orders.user_id = users.id&lt;/code&gt; and programmatically identify the &lt;code&gt;table.column&lt;/code&gt; for each result - navigating not just joins but also more complex syntax like CTEs.&lt;/p&gt;
&lt;p&gt;I decided to set Claude Code (Opus 4.8, since Fable is currently &lt;a href="https://simonwillison.net/2026/Jun/13/us-government-directive-to-suspend-access/"&gt;banned by the US government&lt;/a&gt;) on the problem. It found several promising solutions - one using &lt;a href="https://github.com/rogerbinns/apsw"&gt;apsw&lt;/a&gt;, another that uses &lt;code&gt;ctypes&lt;/code&gt; to access the SQLite &lt;code&gt;sqlite3_column_table_name()&lt;/code&gt; &lt;a href="https://sqlite.org/c3ref/column_database_name.html"&gt;C function&lt;/a&gt; (which is not otherwise exposed to Python), and one using clever interrogation of the output of &lt;code&gt;EXPLAIN&lt;/code&gt;.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="python"/><category term="sqlite"/><category term="datasette"/></entry><entry><title>datasette 1.0a31</title><link href="https://simonwillison.net/2026/May/29/datasette/#atom-tag" rel="alternate"/><published>2026-05-29T03:32:02+00:00</published><updated>2026-05-29T03:32:02+00:00</updated><id>https://simonwillison.net/2026/May/29/datasette/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/datasette/releases/tag/1.0a31"&gt;datasette 1.0a31&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;Another significant alpha release, with two new headline features.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Datasette now offers users with the necessary permissions the ability to both &lt;strong&gt;execute write queries&lt;/strong&gt; against their database and to &lt;strong&gt;save stored queries&lt;/strong&gt; (renamed from "canned queries") both privately and for use by other members of their Datasette instance.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;There's more detail in &lt;a href="https://datasette.io/blog/2026/sql-write-queries/"&gt;SQL write queries and stored queries in Datasette 1.0a31&lt;/a&gt; on the Datasette blog, which now has &lt;a href="https://datasette.io/blog/"&gt;three posts introducing new features&lt;/a&gt; since the blog launched two weeks ago.&lt;/p&gt;
&lt;p&gt;Here's an animated demo from &lt;a href="https://datasette.io/blog/2026/sql-write-queries/"&gt;the blog post&lt;/a&gt; showing how the new execute query interface lets people get started with templated insert/update/delete queries from tables they have permission to edit:&lt;/p&gt;
&lt;p&gt;&lt;img alt="The user starts on the data database page, selects actions and &amp;quot;Execute write SQL&amp;quot;, then selects the insert document template on the next page and executes it with a title of &amp;quot;My document!&amp;quot;. Also demonstrates that a create table statement cannot be executed because the user does not have create-table permission." src="https://datasette.io/static/blog/2026/sql-write-ui.gif" /&gt;&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sql"&gt;sql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="sql"/><category term="sqlite"/><category term="datasette"/><category term="annotated-release-notes"/></entry><entry><title>sqlite AGENTS.md</title><link href="https://simonwillison.net/2026/May/27/sqlite-agents/#atom-tag" rel="alternate"/><published>2026-05-27T23:44:37+00:00</published><updated>2026-05-27T23:44:37+00:00</updated><id>https://simonwillison.net/2026/May/27/sqlite-agents/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/sqlite/sqlite/blob/master/AGENTS.md"&gt;sqlite AGENTS.md&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
SQLite gained an AGENTS.md file &lt;a href="https://github.com/sqlite/sqlite/commit/a1e5778889252d2609a59fd9b819d70392c5789e"&gt;five days ago&lt;/a&gt; - but it's not intended for their own development, it's presumably aimed at people who are pointing agents at the SQLite codebase. It includes:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;SQLite does not accept pull requests without prior agreement and/or accompanying legal paperwork that places the pull request in the public domain. However, the human SQLite developers will review a concise and well-written pull request as a proof-of-concept prior to reimplementing the changes themselves.&lt;/p&gt;
&lt;p&gt;SQLite does not accept agentic code. However the project will accept agentic bug reports that include a reproducible test case. Patches or pull requests demonstrating a possible fix, for documentation purposes, are welcomed.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;a href="https://github.com/sqlite/sqlite/commit/db7fe319ed5a18dbc732ab8eacea557f41cd910f"&gt;most recent commit&lt;/a&gt; to that file removed "(currently)" from "SQLite does not (currently) accept agentic code", with the commit message "Strengthen the statement about not accepting agentic code".&lt;/p&gt;
&lt;p&gt;Meanwhile the SQLite forum was being flooded with so many AI-generated bug reports - of varying quality - that they've now &lt;a href="https://sqlite.org/forum/forumpost/2e7a8d6ba4b46d8315e80fd4a1e2feb40948dff5b7b11d5ba9cea5cb40aa252b"&gt;split those off&lt;/a&gt; into a &lt;a href="https://sqlite.org/bugs/forum"&gt;new SQLite Bug Forum&lt;/a&gt;. D. Richard Hipp is resolving issues on there with a flurry of commits to the codebase.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://discord.com/channels/823971286308356157/1097032579812687943/1507447792598253748"&gt;Alex Garcia on the Datasette Discord&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/d-richard-hipp"&gt;d-richard-hipp&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-security-research"&gt;ai-security-research&lt;/a&gt;&lt;/p&gt;



</summary><category term="sqlite"/><category term="ai"/><category term="d-richard-hipp"/><category term="generative-ai"/><category term="llms"/><category term="coding-agents"/><category term="ai-security-research"/></entry><entry><title>Datasette Agent</title><link href="https://simonwillison.net/2026/May/21/datasette-agent/#atom-tag" rel="alternate"/><published>2026-05-21T19:52:19+00:00</published><updated>2026-05-21T19:52:19+00:00</updated><id>https://simonwillison.net/2026/May/21/datasette-agent/#atom-tag</id><summary type="html">
    &lt;p&gt;We just &lt;a href="https://datasette.io/blog/2026/datasette-agent/"&gt;announced the first release of Datasette Agent&lt;/a&gt;, a new extensible AI assistant for Datasette. I've been working on my &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; Python library for just over three years now, and Datasette Agent represents the moment that LLM and &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; finally come together. I'm really excited about it!&lt;/p&gt;
&lt;p&gt;Datasette Agent provides a conversational interface for asking questions of the data you have stored in Datasette. Add the &lt;a href="https://github.com/datasette/datasette-agent-charts"&gt;datasette-agent-charts&lt;/a&gt; plugin and it can generate charts of your data as well.&lt;/p&gt;
&lt;h4 id="the-demo"&gt;The demo&lt;/h4&gt;
&lt;p&gt;The &lt;a href=""&gt;announcement post&lt;/a&gt; (on the new Datasette project blog) includes this &lt;a href="https://www.youtube.com/watch?v=AFZKp6hbFjI"&gt;demo video&lt;/a&gt;:&lt;/p&gt;

&lt;iframe style="margin-bottom: 1.5em;" width="560" height="315" src="https://www.youtube-nocookie.com/embed/AFZKp6hbFjI" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="allowfullscreen"&gt; &lt;/iframe&gt;


&lt;p&gt;I recorded the video against the new &lt;a href="https://agent.datasette.io/"&gt;agent.datasette.io&lt;/a&gt; live demo instance, which runs Datasette Agent against example databases including the classic &lt;a href="https://datasette.io/global-power-plants"&gt;global-power-plants&lt;/a&gt; by &lt;a href="https://www.wri.org/research/global-database-power-plants"&gt;WRI&lt;/a&gt;, and a copy of the &lt;a href="https://datasette.simonwillison.net/"&gt;Datasette backup&lt;/a&gt; of my blog.&lt;/p&gt;
&lt;p&gt;The live demo runs on &lt;a href="https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-lite"&gt;Gemini 3.1 Flash-Lite&lt;/a&gt; - it's cheap, fast and has no trouble writing SQLite queries.&lt;/p&gt;
&lt;p&gt;A question I asked in the demo was:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;when did Simon most recently see a pelican?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Which ran &lt;a href="https://datasette.simonwillison.net/simonwillisonblog?sql=SELECT+title%2C+commentary%2C+created+FROM+blog_beat+WHERE+beat_type+%3D+%27sighting%27+AND+%28title+LIKE+%27%25pelican%25%27+OR+commentary+LIKE+%27%25pelican%25%27%29+ORDER+BY+created+DESC+LIMIT+5"&gt;this SQL query&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;SELECT&lt;/span&gt;
  title, commentary, created
&lt;span class="pl-k"&gt;FROM&lt;/span&gt;
  blog_beat
&lt;span class="pl-k"&gt;WHERE&lt;/span&gt;
  beat_type &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;sighting&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-k"&gt;AND&lt;/span&gt; (title &lt;span class="pl-k"&gt;LIKE&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%pelican%&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;OR&lt;/span&gt; commentary &lt;span class="pl-k"&gt;LIKE&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;%pelican%&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;)
&lt;span class="pl-k"&gt;ORDER BY&lt;/span&gt;
  created &lt;span class="pl-k"&gt;DESC&lt;/span&gt;
&lt;span class="pl-k"&gt;LIMIT&lt;/span&gt;
  &lt;span class="pl-c1"&gt;5&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And replied:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The most recent sighting of a pelican by Simon was recorded on &lt;strong&gt;May 20, 2026&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The observation included a California Brown Pelican, along with a Common Loon, Canada Goose, Striped Shore Crab, and a California Sea Lion.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's &lt;a href="https://simonwillison.net/2026/May/20/sighting-363395265/"&gt;that sighting on my blog&lt;/a&gt;, and the &lt;a href="https://gist.github.com/simonw/a46d17b69659a4866adb1d868280091d"&gt;Markdown export&lt;/a&gt; of the full conversation transcript.&lt;/p&gt;
&lt;h4 id="the-plugins"&gt;The plugins&lt;/h4&gt;
&lt;p&gt;My favorite feature of Datasette Agent is that, like the rest of Datasette, it's extensible using plugins.&lt;/p&gt;
&lt;p&gt;We've shipped three plugins so far:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/datasette/datasette-agent-charts"&gt;datasette-agent-charts&lt;/a&gt;, shown in the video, adds charts to Datasette Agent, powered by &lt;a href="https://observablehq.com/plot/"&gt;Observable Plot&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/datasette/datasette-agent-openai-imagegen"&gt;datasette-agent-openai-imagegen&lt;/a&gt; adds an image generation tool to Datasette Agent using &lt;a href="https://openai.com/index/introducing-chatgpt-images-2-0/"&gt;ChatGPT Images 2.0&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/datasette/datasette-agent-sprites"&gt;datasette-agent-sprites&lt;/a&gt; provides tools for executing code in a &lt;a href="https://sprites.dev/"&gt;Fly Sprites&lt;/a&gt; persistent sandbox.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Building plugins is &lt;em&gt;really fun&lt;/em&gt;. I have a bunch more prototypes that aren't quite alpha-quality yet.&lt;/p&gt;
&lt;p&gt;Claude Code and OpenAI Codex are both proving excellent at writing plugins - just point them at a checkout of the &lt;a href="https://github.com/datasette/datasette-agent"&gt;datasette-agent repo&lt;/a&gt; for reference and tell them what you want to build!&lt;/p&gt;
&lt;h4 id="running-it-against-local-models"&gt;Running it against local models&lt;/h4&gt;
&lt;p&gt;I've also been having fun running the new plugin against local models. Here's a &lt;code&gt;uv&lt;/code&gt; one-liner to run the plugin against &lt;a href="https://huggingface.co/google/gemma-4-26B-A4B"&gt;gemma-4-26b-a4b&lt;/a&gt; in &lt;a href="https://lmstudio.ai"&gt;LM Studio&lt;/a&gt; on a Mac:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx --prerelease=allow \
  --with datasette-agent --with llm-lmstudio \
  datasette --internal internal.db --root \
  -s plugins.datasette-llm.default_model lmstudio/google/gemma-4-26b-a4b \
  data.db&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Datasette Agent needs reliable tool calls and the ability for a model to produce SQL queries that run against SQLite. The open weight models released in the past six months are increasingly able to handle that.&lt;/p&gt;
&lt;h4 id="what-s-next"&gt;What's next&lt;/h4&gt;
&lt;p&gt;Datasette Agent opens up &lt;em&gt;so many&lt;/em&gt; opportunities for the LLM and Datasette ecosystem in general.&lt;/p&gt;
&lt;p&gt;It's already informed &lt;a href="https://simonwillison.net/2026/Apr/29/llm/"&gt;the major LLM 0.32a0 refactor&lt;/a&gt; which I'm nearly ready to roll into a stable release, maybe with some additional "LLM agent" abstractions extracte from Datasette Agent itself.&lt;/p&gt;
&lt;p&gt;I've been exploring my own take on the Claude Artifacts, which is shaping up nicely as a plugin.&lt;/p&gt;
&lt;p&gt;I'm excited to use Datasette Agent to build my own &lt;a href="https://simonwillison.net/2026/May/19/5-minute-llms/#5-minutes-llms.013.jpeg"&gt;Claw&lt;/a&gt; - a personal AI assistant built around data imported from different parts of my digital life, which is a neat excuse to revisit my older &lt;a href="https://dogsheep.github.io"&gt;Dogsheep&lt;/a&gt; family of tools.&lt;/p&gt;
&lt;p&gt;We'll also be rolling out Datasette Agent for users of &lt;a href="https://datasette.cloud/"&gt;Datasette Cloud&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Join our &lt;a href="https://discord.gg/hdxyusUFv"&gt;#datasette-agent Discord channel&lt;/a&gt; if you'd like to talk about the project.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-agent"&gt;datasette-agent&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="sqlite"/><category term="ai"/><category term="datasette"/><category term="generative-ai"/><category term="llms"/><category term="llm"/><category term="uv"/><category term="datasette-agent"/></entry><entry><title>Quoting Andrew Quinn</title><link href="https://simonwillison.net/2026/May/10/andrew-quinn/#atom-tag" rel="alternate"/><published>2026-05-10T14:59:17+00:00</published><updated>2026-05-10T14:59:17+00:00</updated><id>https://simonwillison.net/2026/May/10/andrew-quinn/#atom-tag</id><summary type="html">
    &lt;blockquote cite="https://til.andrew-quinn.me/posts/replacing-a-3-gb-sqlite-database-with-a-7-mb-fst-finite-state-trandsucer-binary/#fn:5"&gt;&lt;p&gt;One could say in the first quarter-century of my life, that while I was always fascinated by programming, I could never overcome the guilt of not really knowing whether the tool I am building right now isn’t already superceded by some much better implementation someone else has already written 30 or 40 years ago; I could write a TSV-aware search and replace, or I could find out about &lt;code&gt;awk&lt;/code&gt; and solve that entire class of problems in one fell swoop, for example. My central conceit is that &lt;em&gt;this is a trap&lt;/em&gt;. You &lt;em&gt;need&lt;/em&gt; to reinvent a couple of wheels to get to the edge of what we know about wheel-making, not a thousand wheels, and not zero; probably four or five is sufficient in most domains, maybe closer to twenty or thirty in the most epistemically rigorous and developed fields like mathematics or computer science. Each wheel you reinvent, and every directed question you ask along the way, will propel you faster to the true frontier than that same amount of time spend in idle study, or even five times that amount.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p class="cite"&gt;&amp;mdash; &lt;a href="https://til.andrew-quinn.me/posts/replacing-a-3-gb-sqlite-database-with-a-7-mb-fst-finite-state-trandsucer-binary/#fn:5"&gt;Andrew Quinn&lt;/a&gt;, footnote on Replacing a 3 GB SQLite database with a 10 MB FST (finite state transducer) binary&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/careers"&gt;careers&lt;/a&gt;&lt;/p&gt;



</summary><category term="sqlite"/><category term="careers"/></entry><entry><title>russellromney/honker</title><link href="https://simonwillison.net/2026/Apr/24/honker/#atom-tag" rel="alternate"/><published>2026-04-24T01:50:07+00:00</published><updated>2026-04-24T01:50:07+00:00</updated><id>https://simonwillison.net/2026/Apr/24/honker/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/russellromney/honker"&gt;russellromney/honker&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
"Postgres NOTIFY/LISTEN semantics" for SQLite, implemented as a Rust SQLite extension and various language bindings to help make use of it.&lt;/p&gt;
&lt;p&gt;The design of this looks very solid. It lets you write Python code for queues that looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;honker&lt;/span&gt;

&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;honker&lt;/span&gt;.&lt;span class="pl-c1"&gt;open&lt;/span&gt;(&lt;span class="pl-s"&gt;"app.db"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;emails&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;queue&lt;/span&gt;(&lt;span class="pl-s"&gt;"emails"&lt;/span&gt;)
&lt;span class="pl-c1"&gt;emails&lt;/span&gt;.&lt;span class="pl-c1"&gt;enqueue&lt;/span&gt;({&lt;span class="pl-s"&gt;"to"&lt;/span&gt;: &lt;span class="pl-s"&gt;"alice@example.com"&lt;/span&gt;})

&lt;span class="pl-c"&gt;# Consume (in a worker process)&lt;/span&gt;
&lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;job&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;emails&lt;/span&gt;.&lt;span class="pl-c1"&gt;claim&lt;/span&gt;(&lt;span class="pl-s"&gt;"worker-1"&lt;/span&gt;):
    &lt;span class="pl-en"&gt;send&lt;/span&gt;(&lt;span class="pl-s1"&gt;job&lt;/span&gt;.&lt;span class="pl-c1"&gt;payload&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;job&lt;/span&gt;.&lt;span class="pl-c1"&gt;ack&lt;/span&gt;()&lt;/pre&gt;
&lt;p&gt;And Kafka-style durable streams like this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;stream&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;stream&lt;/span&gt;(&lt;span class="pl-s"&gt;"user-events"&lt;/span&gt;)

&lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;transaction&lt;/span&gt;() &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-s1"&gt;tx&lt;/span&gt;:
    &lt;span class="pl-s1"&gt;tx&lt;/span&gt;.&lt;span class="pl-c1"&gt;execute&lt;/span&gt;(&lt;span class="pl-s"&gt;"UPDATE users SET name=? WHERE id=?"&lt;/span&gt;, [&lt;span class="pl-s1"&gt;name&lt;/span&gt;, &lt;span class="pl-s1"&gt;uid&lt;/span&gt;])
    &lt;span class="pl-s1"&gt;stream&lt;/span&gt;.&lt;span class="pl-c1"&gt;publish&lt;/span&gt;({&lt;span class="pl-s"&gt;"user_id"&lt;/span&gt;: &lt;span class="pl-s1"&gt;uid&lt;/span&gt;, &lt;span class="pl-s"&gt;"change"&lt;/span&gt;: &lt;span class="pl-s"&gt;"name"&lt;/span&gt;}, &lt;span class="pl-s1"&gt;tx&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;tx&lt;/span&gt;)

&lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;stream&lt;/span&gt;.&lt;span class="pl-c1"&gt;subscribe&lt;/span&gt;(&lt;span class="pl-s1"&gt;consumer&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"dashboard"&lt;/span&gt;):
    &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-en"&gt;push_to_browser&lt;/span&gt;(&lt;span class="pl-s1"&gt;event&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;It also adds 20+ custom SQL functions including these two:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;SELECT&lt;/span&gt; notify(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;orders&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{"id":42}&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;);
&lt;span class="pl-k"&gt;SELECT&lt;/span&gt; honker_stream_read_since(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;orders&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-c1"&gt;0&lt;/span&gt;, &lt;span class="pl-c1"&gt;1000&lt;/span&gt;);&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The extension requires WAL mode, and workers can poll the &lt;code&gt;.db-wal&lt;/code&gt; file with a stat call every 1ms to get as close to real-time as possible without the expense of running a full SQL query.&lt;/p&gt;
&lt;p&gt;honker implements the &lt;strong&gt;transactional outbox pattern&lt;/strong&gt;, which ensures items are only queued if a transaction successfully commits. My favorite explanation of that pattern remains &lt;a href="https://brandur.org/job-drain"&gt;Transactionally Staged Job Drains in Postgres&lt;/a&gt; by Brandur Leach. It's great to see a new implementation of that pattern for SQLite.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://news.ycombinator.com/item?id=47874647"&gt;Show HN&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/databases"&gt;databases&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/rust"&gt;rust&lt;/a&gt;&lt;/p&gt;



</summary><category term="databases"/><category term="postgresql"/><category term="sqlite"/><category term="rust"/></entry><entry><title>Serving the For You feed</title><link href="https://simonwillison.net/2026/Apr/24/serving-the-for-you-feed/#atom-tag" rel="alternate"/><published>2026-04-24T01:08:17+00:00</published><updated>2026-04-24T01:08:17+00:00</updated><id>https://simonwillison.net/2026/Apr/24/serving-the-for-you-feed/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://atproto.com/blog/serving-the-for-you-feed"&gt;Serving the For You feed&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
One of Bluesky's most interesting features is that anyone can run their own &lt;a href="bluesky custom feed"&gt;custom "feed" implementation&lt;/a&gt; and make it available to other users - effectively enabling custom algorithms that can use any mechanism they like to recommend posts.&lt;/p&gt;
&lt;p&gt;spacecowboy runs the &lt;a href="https://bsky.app/profile/did:plc:3guzzweuqraryl3rdkimjamk/feed/for-you"&gt;For You Feed&lt;/a&gt;, used by around 72,000 people. This guest post on the AT Protocol blog explains how it works.&lt;/p&gt;
&lt;p&gt;The architecture is &lt;em&gt;fascinating&lt;/em&gt;. The feed is served by a single Go process using SQLite on a "gaming" PC in spacecowboy's living room - 16 cores, 96GB of RAM and 4TB of attached NVMe storage.&lt;/p&gt;
&lt;p&gt;Recommendations are based on likes: what else are the people who like the same things as you liking on the platform?&lt;/p&gt;
&lt;p&gt;That Go server consumes the Bluesky firehose and stores the relevant details in SQLite, keeping the last 90 days of relevant data, which currently uses around 419GB of SQLite storage.&lt;/p&gt;
&lt;p&gt;Public internet traffic is handled by a $7/month VPS on OVH, which talks to the living room server via Tailscale.&lt;/p&gt;
&lt;p&gt;Total cost is now $30/month: $20 in electricity, $7 in VPS and $3 for the two domain names. spacecowboy estimates that the existing system could handle all ~1 million daily active Bluesky users if they were to switch to the cheapest algorithm they have found to work.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/go"&gt;go&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/scaling"&gt;scaling&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/software-architecture"&gt;software-architecture&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tailscale"&gt;tailscale&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/bluesky"&gt;bluesky&lt;/a&gt;&lt;/p&gt;



</summary><category term="go"/><category term="scaling"/><category term="sqlite"/><category term="software-architecture"/><category term="tailscale"/><category term="bluesky"/></entry><entry><title>SQLite 3.53.0</title><link href="https://simonwillison.net/2026/Apr/11/sqlite/#atom-tag" rel="alternate"/><published>2026-04-11T19:56:53+00:00</published><updated>2026-04-11T19:56:53+00:00</updated><id>https://simonwillison.net/2026/Apr/11/sqlite/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://sqlite.org/releaselog/3_53_0.html"&gt;SQLite 3.53.0&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
SQLite 3.52.0 was withdrawn so this is a pretty big release with a whole lot of accumulated user-facing and internal improvements. Some that stood out to me:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ALTER TABLE&lt;/code&gt; can now add and remove &lt;code&gt;NOT NULL&lt;/code&gt; and &lt;code&gt;CHECK&lt;/code&gt; constraints - I've previously used my own &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#changing-not-null-status"&gt;sqlite-utils transform() method&lt;/a&gt; for this.&lt;/li&gt;
&lt;li&gt;New &lt;a href="https://sqlite.org/json1.html#jarrayins"&gt;json_array_insert() function&lt;/a&gt; and its &lt;code&gt;jsonb&lt;/code&gt; equivalent.&lt;/li&gt;
&lt;li&gt;Significant improvements to &lt;a href="https://sqlite.org/climode.html"&gt;CLI mode&lt;/a&gt;, including result formatting.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result formatting improvements come from a new library, the &lt;a href="https://sqlite.org/src/file/ext/qrf"&gt;Query Results Formatter&lt;/a&gt;. I &lt;a href="https://github.com/simonw/tools/pull/266"&gt;had Claude Code&lt;/a&gt; (on my phone) compile that to WebAssembly and build &lt;a href="https://tools.simonwillison.net/sqlite-qrf"&gt;this playground interface&lt;/a&gt; for trying that out.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://lobste.rs/s/sqsb24/sqlite_3_53_0"&gt;Lobste.rs&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sql"&gt;sql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;&lt;/p&gt;



</summary><category term="sql"/><category term="sqlite"/></entry><entry><title>SQLite Query Result Formatter Demo</title><link href="https://simonwillison.net/2026/Apr/11/sqlite-qrf/#atom-tag" rel="alternate"/><published>2026-04-11T19:35:31+00:00</published><updated>2026-04-11T19:35:31+00:00</updated><id>https://simonwillison.net/2026/Apr/11/sqlite-qrf/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/sqlite-qrf"&gt;SQLite Query Result Formatter Demo&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;See my notes &lt;a href="https://simonwillison.net/2026/Apr/11/sqlite/"&gt;on SQLite 3.53.0&lt;/a&gt;. This playground provides a UI for trying out the various rendering options for SQL result tables from the new Query Result Formatter library, compiled to WebAssembly.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="sqlite"/><category term="tools"/><category term="webassembly"/></entry><entry><title>SQLite WAL Mode Across Docker Containers Sharing a Volume</title><link href="https://simonwillison.net/2026/Apr/7/sqlite-wal-docker-containers/#atom-tag" rel="alternate"/><published>2026-04-07T15:41:00+00:00</published><updated>2026-04-07T15:41:00+00:00</updated><id>https://simonwillison.net/2026/Apr/7/sqlite-wal-docker-containers/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/sqlite-wal-docker-containers#readme"&gt;SQLite WAL Mode Across Docker Containers Sharing a Volume&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;Inspired by &lt;a href="https://news.ycombinator.com/item?id=47637353"&gt;this conversation&lt;/a&gt; on Hacker News about whether two SQLite processes in separate Docker containers that share the same volume might run into problems due to WAL shared memory. The answer is that everything works fine - Docker containers on the same host and filesystem share the same shared memory in a way that allows WAL to collaborate as it should.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/docker"&gt;docker&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="sqlite"/><category term="docker"/></entry><entry><title>Eight years of wanting, three months of building with AI</title><link href="https://simonwillison.net/2026/Apr/5/building-with-ai/#atom-tag" rel="alternate"/><published>2026-04-05T23:54:18+00:00</published><updated>2026-04-05T23:54:18+00:00</updated><id>https://simonwillison.net/2026/Apr/5/building-with-ai/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://lalitm.com/post/building-syntaqlite-ai/"&gt;Eight years of wanting, three months of building with AI&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Lalit Maganti provides one of my favorite pieces of long-form writing on agentic engineering I've seen in ages.&lt;/p&gt;
&lt;p&gt;They spent eight years thinking about and then three months building &lt;a href="https://github.com/lalitMaganti/syntaqlite"&gt;syntaqlite&lt;/a&gt;, which they describe as "&lt;a href="https://lalitm.com/post/syntaqlite/"&gt;high-fidelity devtools that SQLite deserves&lt;/a&gt;".&lt;/p&gt;
&lt;p&gt;The goal was to provide fast, robust and comprehensive linting and verifying tools for SQLite, suitable for use in language servers and other development tools - a parser, formatter, and verifier for SQLite queries. I've found myself wanting this kind of thing in the past myself, hence my (far less production-ready) &lt;a href="https://simonwillison.net/2026/Jan/30/sqlite-ast-2/"&gt;sqlite-ast&lt;/a&gt; project from a few months ago.&lt;/p&gt;
&lt;p&gt;Lalit had been procrastinating on this project for years, because of the inevitable tedium of needing to work through 400+ grammar rules to help build a parser. That's exactly the kind of tedious work that coding agents excel at!&lt;/p&gt;
&lt;p&gt;Claude Code helped get over that initial hump and build the first prototype:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;AI basically let me put aside all my doubts on technical calls, my uncertainty of building the right thing and my reluctance to get started by giving me very concrete problems to work on. Instead of “I need to understand how SQLite’s parsing works”, it was “I need to get AI to suggest an approach for me so I can tear it up and build something better". I work so much better with concrete prototypes to play with and code to look at than endlessly thinking about designs in my head, and AI lets me get to that point at a pace I could not have dreamed about before. Once I took the first step, every step after that was so much easier.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That first vibe-coded prototype worked great as a proof of concept, but they eventually made the decision to throw it away and start again from scratch. AI worked great for the low level details but did not produce a coherent high-level architecture:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I found that AI made me procrastinate on key design decisions. Because refactoring was cheap, I could always say “I’ll deal with this later.” And because AI could refactor at the same industrial scale it generated code, the cost of deferring felt low. But it wasn’t: deferring decisions corroded my ability to think clearly because the codebase stayed confusing in the meantime.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The second attempt took a lot longer and involved a great deal more human-in-the-loop decision making, but the result is a robust library that can stand the test of time.&lt;/p&gt;
&lt;p&gt;It's worth setting aside some time to read this whole thing - it's full of non-obvious downsides to working heavily with AI, as well as a detailed explanation of how they overcame those hurdles.&lt;/p&gt;
&lt;p&gt;The key idea I took away from this concerns AI's weakness in terms of design and architecture:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;When I was working on something where I didn’t even know what I wanted, AI was somewhere between unhelpful and harmful. The architecture of the project was the clearest case: I spent weeks in the early days following AI down dead ends, exploring designs that felt productive in the moment but collapsed under scrutiny. In hindsight, I have to wonder if it would have been faster just thinking it through without AI in the loop at all.&lt;/p&gt;
&lt;p&gt;But expertise alone isn’t enough. Even when I understood a problem deeply, AI still struggled if the task had no objectively checkable answer. Implementation has a right answer, at least at a local level: the code compiles, the tests pass, the output matches what you asked for. Design doesn’t. We’re still arguing about OOP decades after it first took off.&lt;/p&gt;
&lt;/blockquote&gt;

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://news.ycombinator.com/item?id=47648828"&gt;Hacker News&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vibe-coding"&gt;vibe-coding&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;&lt;/p&gt;



</summary><category term="sqlite"/><category term="ai"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="vibe-coding"/><category term="agentic-engineering"/></entry><entry><title>Syntaqlite Playground</title><link href="https://simonwillison.net/2026/Apr/5/syntaqlite/#atom-tag" rel="alternate"/><published>2026-04-05T19:32:59+00:00</published><updated>2026-04-05T19:32:59+00:00</updated><id>https://simonwillison.net/2026/Apr/5/syntaqlite/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/syntaqlite"&gt;Syntaqlite Playground&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;Lalit Maganti's &lt;a href="https://github.com/LalitMaganti/syntaqlite"&gt;syntaqlite&lt;/a&gt; is currently being discussed &lt;a href="https://news.ycombinator.com/item?id=47648828"&gt;on Hacker News&lt;/a&gt; thanks to &lt;a href="https://lalitm.com/post/building-syntaqlite-ai/"&gt;Eight years of wanting, three months of building with AI&lt;/a&gt;, a deep dive into how it was built.&lt;/p&gt;
&lt;p&gt;This inspired me to revisit &lt;a href="https://github.com/simonw/research/tree/main/syntaqlite-python-extension#readme"&gt;a research project&lt;/a&gt; I ran when Lalit first released it a couple of weeks ago, where I tried it out and then compiled it to a WebAssembly wheel so it could run in Pyodide in a browser (the library itself uses C and Rust).&lt;/p&gt;
&lt;p&gt;This &lt;a href="https://tools.simonwillison.net/syntaqlite"&gt;new playground&lt;/a&gt; loads up the Python library and provides a UI for trying out its different features: formating, parsing into an AST, validating, and tokenizing SQLite SQL queries.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/syntaqlite-playground.jpg" alt="Screenshot of a dark-themed SQL validation playground called SyntaqLite. The &amp;quot;Validate&amp;quot; tab is selected from options including Format, Parse, Validate, and Tokenize. The SQL input contains &amp;quot;SELECT id, name FROM usr WHERE active = 1&amp;quot; with a schema defining &amp;quot;users&amp;quot; and &amp;quot;posts&amp;quot; tables. Example buttons for &amp;quot;Table typo&amp;quot;, &amp;quot;Column typo&amp;quot;, and &amp;quot;Valid query&amp;quot; are shown above a red &amp;quot;Validate SQL&amp;quot; button. The Diagnostics panel shows an error for unknown table 'usr' with the suggestion &amp;quot;did you mean 'users'?&amp;quot;, and the JSON panel displays the corresponding error object with severity, message, and offset fields."&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update&lt;/strong&gt;: not sure how I missed this but &lt;a href="https://playground.syntaqlite.com/#p=sqlite-basic-select"&gt;syntaqlite has its own WebAssembly playground&lt;/a&gt; linked to from the README.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sql"&gt;sql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="sql"/><category term="sqlite"/><category term="tools"/><category term="webassembly"/><category term="ai-assisted-programming"/><category term="agentic-engineering"/></entry><entry><title>SQLite Tags Benchmark: Comparing 5 Tagging Strategies</title><link href="https://simonwillison.net/2026/Mar/20/sqlite-tags-benchmark/#atom-tag" rel="alternate"/><published>2026-03-20T02:57:00+00:00</published><updated>2026-03-20T02:57:00+00:00</updated><id>https://simonwillison.net/2026/Mar/20/sqlite-tags-benchmark/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/sqlite-tags-benchmark#readme"&gt;SQLite Tags Benchmark: Comparing 5 Tagging Strategies&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;I had Claude Code run a micro-benchmark comparing different approaches to implementing tagging in SQLite. Traditional many-to-many tables won, but FTS5 came a close second. Full table scans with LIKE queries performed better than I expected, but full table scans with JSON arrays and &lt;code&gt;json_each()&lt;/code&gt; were much slower.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/json"&gt;json&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="json"/><category term="sqlite"/></entry><entry><title>Coding agents for data analysis</title><link href="https://simonwillison.net/2026/Mar/16/coding-agents-for-data-analysis/#atom-tag" rel="alternate"/><published>2026-03-16T20:12:32+00:00</published><updated>2026-03-16T20:12:32+00:00</updated><id>https://simonwillison.net/2026/Mar/16/coding-agents-for-data-analysis/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://simonw.github.io/nicar-2026-coding-agents/"&gt;Coding agents for data analysis&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Here's the handout I prepared for my NICAR 2026 workshop "Coding agents for data analysis" - a three hour session aimed at data journalists demonstrating ways that tools like Claude Code and OpenAI Codex can be used to explore, analyze and clean data.&lt;/p&gt;
&lt;p&gt;Here's the table of contents:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://simonw.github.io/nicar-2026-coding-agents/coding-agents.html"&gt;Coding agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonw.github.io/nicar-2026-coding-agents/warmup.html"&gt;Warmup: ChatGPT and Claude&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonw.github.io/nicar-2026-coding-agents/setup.html"&gt;Setup Claude Code and Codex&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonw.github.io/nicar-2026-coding-agents/asking-questions.html"&gt;Asking questions against a database&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonw.github.io/nicar-2026-coding-agents/exploring-data.html"&gt;Exploring data with agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonw.github.io/nicar-2026-coding-agents/cleaning-trees.html"&gt;Cleaning data: decoding neighborhood codes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonw.github.io/nicar-2026-coding-agents/visualizations.html"&gt;Creating visualizations with agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonw.github.io/nicar-2026-coding-agents/scraping.html"&gt;Scraping data with agents&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I ran the workshop using GitHub Codespaces and OpenAI Codex, since it was easy (and inexpensive) to distribute a budget-restricted API key for Codex that attendees could use during the class. Participants ended up burning $23 of Codex tokens.&lt;/p&gt;
&lt;p&gt;The exercises all used Python and SQLite and some of them used Datasette.&lt;/p&gt;
&lt;p&gt;One highlight of the workshop was when we started &lt;a href="https://simonw.github.io/nicar-2026-coding-agents/visualizations.html#javascript-visualizations"&gt;running Datasette&lt;/a&gt; such that it served static content from a &lt;code&gt;viz/&lt;/code&gt; folder, then had Claude Code start vibe coding new interactive visualizations directly in that folder. Here's a heat map it created for my trees database using Leaflet and &lt;a href="https://github.com/Leaflet/Leaflet.heat"&gt;Leaflet.heat&lt;/a&gt;, &lt;a href="https://gist.github.com/simonw/985ae2a6a3cd3df3fd375eb58dabea0f"&gt;source code here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a &amp;quot;Trees SQL Map&amp;quot; web application with the heading &amp;quot;Trees SQL Map&amp;quot; and subheading &amp;quot;Run a query and render all returned points as a heat map. The default query targets roughly 200,000 trees.&amp;quot; Below is an input field containing &amp;quot;/trees/-/query.json&amp;quot;, a &amp;quot;Run Query&amp;quot; button, and a SQL query editor with the text &amp;quot;SELECT cast(Latitude AS float) AS latitude, cast(Longitude AS float) AS longitude, CASE WHEN DBH IS NULL OR DBH = '' THEN 0.3 WHEN cast(DBH AS float) &amp;lt;= 0 THEN 0.3 WHEN cast(DBH AS float) &amp;gt;= 80 THEN 1.0&amp;quot; (query is truncated). A status message reads &amp;quot;Loaded 1,000 rows and plotted 1,000 points as heat map.&amp;quot; Below is a Leaflet/OpenStreetMap interactive map of San Francisco showing a heat map overlay of tree locations, with blue/green clusters concentrated in areas like the Richmond District, Sunset District, and other neighborhoods. Map includes zoom controls and a &amp;quot;Leaflet | © OpenStreetMap contributors&amp;quot; attribution." src="https://static.simonwillison.net/static/2026/tree-sql-map.jpg" /&gt;&lt;/p&gt;
&lt;p&gt;I designed the handout to also be useful for people who weren't able to attend the session in person. As is usually the case, material aimed at data journalists is equally applicable to anyone else with data to explore.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/data-journalism"&gt;data-journalism&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/geospatial"&gt;geospatial&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/speaking"&gt;speaking&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-codespaces"&gt;github-codespaces&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/nicar"&gt;nicar&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/codex"&gt;codex&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/leaflet"&gt;leaflet&lt;/a&gt;&lt;/p&gt;



</summary><category term="data-journalism"/><category term="geospatial"/><category term="python"/><category term="speaking"/><category term="sqlite"/><category term="ai"/><category term="datasette"/><category term="generative-ai"/><category term="llms"/><category term="github-codespaces"/><category term="nicar"/><category term="coding-agents"/><category term="claude-code"/><category term="codex"/><category term="leaflet"/></entry><entry><title>Production query plans without production data</title><link href="https://simonwillison.net/2026/Mar/9/production-query-plans-without-production-data/#atom-tag" rel="alternate"/><published>2026-03-09T15:05:15+00:00</published><updated>2026-03-09T15:05:15+00:00</updated><id>https://simonwillison.net/2026/Mar/9/production-query-plans-without-production-data/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://boringsql.com/posts/portable-stats/"&gt;Production query plans without production data&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Radim Marek describes the new &lt;a href="https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADMIN-STATSMOD"&gt;&lt;code&gt;pg_restore_relation_stats()&lt;/code&gt; and &lt;code&gt;pg_restore_attribute_stats()&lt;/code&gt; functions&lt;/a&gt; that were introduced &lt;a href="https://www.postgresql.org/docs/current/release-18.html"&gt;in PostgreSQL 18&lt;/a&gt; in September 2025.&lt;/p&gt;
&lt;p&gt;The PostgreSQL query planner makes use of internal statistics to help it decide how to best execute a query. These statistics often differ between production data and development environments, which means the query plans used in production may not be replicable in development.&lt;/p&gt;
&lt;p&gt;PostgreSQL's new features now let you copy those statistics down to your development environment, allowing you to simulate the plans for production workloads without needing to copy in all of that data first.&lt;/p&gt;
&lt;p&gt;I found this illustrative example useful:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT pg_restore_attribute_stats(
    'schemaname', 'public',
    'relname', 'test_orders',
    'attname', 'status',
    'inherited', false::boolean,
    'null_frac', 0.0::real,
    'avg_width', 9::integer,
    'n_distinct', 5::real,
    'most_common_vals', '{delivered,shipped,cancelled,pending,returned}'::text,
    'most_common_freqs', '{0.95,0.015,0.015,0.015,0.005}'::real[]
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This simulates statistics for a &lt;code&gt;status&lt;/code&gt; column that is 95% &lt;code&gt;delivered&lt;/code&gt;. Based on these statistics PostgreSQL can decide to use an index for &lt;code&gt;status = 'shipped'&lt;/code&gt; but to instead perform a full table scan for &lt;code&gt;status = 'delivered'&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;These statistics are pretty small. Radim says:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Statistics dumps are tiny. A database with hundreds of tables and thousands of columns produces a statistics dump under 1MB. The production data might be hundreds of GB. The statistics that describe it fit in a text file.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I posted on the SQLite user forum asking if SQLite could offer a similar feature and D. Richard Hipp promptly replied &lt;a href="https://sqlite.org/forum/forumpost/480c5cb8a3898346"&gt;that it has one already&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;All of the data statistics used by the query planner in SQLite are available in the &lt;a href="https://sqlite.org/fileformat.html#the_sqlite_stat1_table"&gt;sqlite_stat1 table&lt;/a&gt; (or also in the &lt;a href="https://sqlite.org/fileformat.html#the_sqlite_stat4_table"&gt;sqlite_stat4 table&lt;/a&gt; if you happen to have compiled with SQLITE_ENABLE_STAT4).  That table is writable. You can inject whatever alternative statistics you like.&lt;/p&gt;
&lt;p&gt;This approach to controlling the query planner is mentioned in the documentation:
&lt;a href="https://sqlite.org/optoverview.html#manual_control_of_query_plans_using_sqlite_stat_tables"&gt;https://sqlite.org/optoverview.html#manual_control_of_query_plans_using_sqlite_stat_tables&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;See also &lt;a href="https://sqlite.org/lang_analyze.html#fixed_results_of_analyze"&gt;https://sqlite.org/lang_analyze.html#fixed_results_of_analyze&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The ".fullschema" command in the CLI outputs both the schema and the content of the sqlite_statN tables, exactly for the reasons outlined above - so that we can reproduce query problems for testing without have to load multi-terabyte database files.&lt;/p&gt;
&lt;/blockquote&gt;

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://lobste.rs/s/o8vbb7/production_query_plans_without"&gt;Lobste.rs&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/databases"&gt;databases&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sql"&gt;sql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/d-richard-hipp"&gt;d-richard-hipp&lt;/a&gt;&lt;/p&gt;



</summary><category term="databases"/><category term="postgresql"/><category term="sql"/><category term="sqlite"/><category term="d-richard-hipp"/></entry><entry><title>cysqlite - a new sqlite driver</title><link href="https://simonwillison.net/2026/Feb/11/cysqlite/#atom-tag" rel="alternate"/><published>2026-02-11T17:34:40+00:00</published><updated>2026-02-11T17:34:40+00:00</updated><id>https://simonwillison.net/2026/Feb/11/cysqlite/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://charlesleifer.com/blog/cysqlite---a-new-sqlite-driver/"&gt;cysqlite - a new sqlite driver&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Charles Leifer has been maintaining &lt;a href="https://github.com/coleifer/pysqlite3"&gt;pysqlite3&lt;/a&gt; - a fork of the Python standard library's &lt;code&gt;sqlite3&lt;/code&gt; module that makes it much easier to run upgraded SQLite versions - since 2018.&lt;/p&gt;
&lt;p&gt;He's been working on a ground-up &lt;a href="https://cython.org/"&gt;Cython&lt;/a&gt; rewrite called &lt;a href="https://github.com/coleifer/cysqlite"&gt;cysqlite&lt;/a&gt; for almost as long, but it's finally at a stage where it's ready for people to try out.&lt;/p&gt;
&lt;p&gt;The biggest change from the &lt;code&gt;sqlite3&lt;/code&gt; module involves transactions. Charles explains his discomfort with the &lt;code&gt;sqlite3&lt;/code&gt; implementation at length - that library provides two different variants neither of which exactly match the autocommit mechanism in SQLite itself.&lt;/p&gt;
&lt;p&gt;I'm particularly excited about the support for &lt;a href="https://cysqlite.readthedocs.io/en/latest/api.html#tablefunction"&gt;custom virtual tables&lt;/a&gt;, a feature I'd love to see in &lt;code&gt;sqlite3&lt;/code&gt; itself.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cysqlite&lt;/code&gt; provides a Python extension compiled from C, which means it normally wouldn't be available in Pyodide. I &lt;a href="https://github.com/simonw/research/tree/main/cysqlite-wasm-wheel"&gt;set Claude Code on it&lt;/a&gt; (here's &lt;a href="https://github.com/simonw/research/pull/79#issue-3923792518"&gt;the prompt&lt;/a&gt;) and it built me &lt;a href="https://github.com/simonw/research/blob/main/cysqlite-wasm-wheel/cysqlite-0.1.4-cp311-cp311-emscripten_3_1_46_wasm32.whl"&gt;cysqlite-0.1.4-cp311-cp311-emscripten_3_1_46_wasm32.whl&lt;/a&gt;, a 688KB wheel file with a WASM build of the library that can be loaded into Pyodide like this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;micropip&lt;/span&gt;
&lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;micropip&lt;/span&gt;.&lt;span class="pl-c1"&gt;install&lt;/span&gt;(
    &lt;span class="pl-s"&gt;"https://simonw.github.io/research/cysqlite-wasm-wheel/cysqlite-0.1.4-cp311-cp311-emscripten_3_1_46_wasm32.whl"&lt;/span&gt;
)
&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;cysqlite&lt;/span&gt;
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;cysqlite&lt;/span&gt;.&lt;span class="pl-c1"&gt;connect&lt;/span&gt;(&lt;span class="pl-s"&gt;":memory:"&lt;/span&gt;).&lt;span class="pl-c1"&gt;execute&lt;/span&gt;(
    &lt;span class="pl-s"&gt;"select sqlite_version()"&lt;/span&gt;
).&lt;span class="pl-c1"&gt;fetchone&lt;/span&gt;())&lt;/pre&gt;

&lt;p&gt;(I also learned that wheels like this have to be built for the emscripten version used by that edition of Pyodide - my experimental wheel loads in Pyodide 0.25.1 but fails in 0.27.5 with a &lt;code&gt;Wheel was built with Emscripten v3.1.46 but Pyodide was built with Emscripten v3.1.58&lt;/code&gt; error.)&lt;/p&gt;
&lt;p&gt;You can try my wheel in &lt;a href="https://7ebbff98.tools-b1q.pages.dev/pyodide-repl"&gt;this new Pyodide REPL&lt;/a&gt; i had Claude build as a mobile-friendly alternative to Pyodide's &lt;a href="https://pyodide.org/en/stable/console.html"&gt;own hosted console&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I also had Claude build &lt;a href="https://simonw.github.io/research/cysqlite-wasm-wheel/demo.html"&gt;this demo page&lt;/a&gt; that executes the original test suite in the browser and displays the results:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of the cysqlite WebAssembly Demo page with a dark theme. Title reads &amp;quot;cysqlite — WebAssembly Demo&amp;quot; with subtitle &amp;quot;Testing cysqlite compiled to WebAssembly via Emscripten, running in Pyodide in the browser.&amp;quot; Environment section shows Pyodide 0.25.1, Python 3.11.3, cysqlite 0.1.4, SQLite 3.51.2, Platform Emscripten-3.1.46-wasm32-32bit, Wheel file cysqlite-0.1.4-cp311-cp311-emscripten_3_1_46_wasm32.wh (truncated). A green progress bar shows &amp;quot;All 115 tests passed! (1 skipped)&amp;quot; at 100%, with Passed: 115, Failed: 0, Errors: 0, Skipped: 1, Total: 116. Test Results section lists TestBackup 1/1 passed, TestBlob 6/6 passed, TestCheckConnection 4/4 passed, TestDataTypesTableFunction 1/1 passed, all with green badges." src="https://static.simonwillison.net/static/2026/cysqlite-tests.jpg" /&gt;

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://lobste.rs/s/gipvta/cysqlite_new_sqlite_driver"&gt;lobste.rs&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/charles-leifer"&gt;charles-leifer&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;&lt;/p&gt;



</summary><category term="python"/><category term="sqlite"/><category term="charles-leifer"/><category term="webassembly"/><category term="pyodide"/><category term="ai-assisted-programming"/><category term="claude-code"/></entry><entry><title>Distributing Go binaries like sqlite-scanner through PyPI using go-to-wheel</title><link href="https://simonwillison.net/2026/Feb/4/distributing-go-binaries/#atom-tag" rel="alternate"/><published>2026-02-04T14:59:47+00:00</published><updated>2026-02-04T14:59:47+00:00</updated><id>https://simonwillison.net/2026/Feb/4/distributing-go-binaries/#atom-tag</id><summary type="html">
    &lt;p&gt;I've been exploring Go for building small, fast and self-contained binary applications recently. I'm enjoying how there's generally one obvious way to do things and the resulting code is boring and readable - and something that LLMs are very competent at writing. The one catch is distribution, but it turns out publishing Go binaries to PyPI means any Go binary can be just a &lt;code&gt;uvx package-name&lt;/code&gt; call away.&lt;/p&gt;
&lt;h4 id="sqlite-scanner"&gt;sqlite-scanner&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/sqlite-scanner"&gt;sqlite-scanner&lt;/a&gt; is my new Go CLI tool for scanning a filesystem for SQLite database files.&lt;/p&gt;
&lt;p&gt;It works by checking if the first 16 bytes of the file exactly match the SQLite magic number sequence &lt;code&gt;SQLite format 3\x00&lt;/code&gt;. It can search one or more folders recursively, spinning up concurrent goroutines to accelerate the scan. It streams out results as it finds them in plain text, JSON or newline-delimited JSON. It can optionally display the file sizes as well.&lt;/p&gt;
&lt;p&gt;To try it out you can download a release from the &lt;a href="https://github.com/simonw/sqlite-scanner/releases"&gt;GitHub releases&lt;/a&gt; - and then &lt;a href="https://support.apple.com/en-us/102445"&gt;jump through macOS hoops&lt;/a&gt; to execute an "unsafe" binary. Or you can clone the repo and compile it with Go. Or... you can run the binary like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx sqlite-scanner
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By default this will search your current directory for SQLite databases. You can pass one or more directories as arguments:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx sqlite-scanner ~ /tmp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add &lt;code&gt;--json&lt;/code&gt; for JSON output, &lt;code&gt;--size&lt;/code&gt; to include file sizes or &lt;code&gt;--jsonl&lt;/code&gt; for newline-delimited JSON. Here's a demo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx sqlite-scanner ~ --jsonl --size
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/sqlite-scanner-demo.gif" alt="running that command produces a sequence of JSON objects, each with a path and a size key" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;If you haven't been uv-pilled yet you can instead install &lt;code&gt;sqlite-scanner&lt;/code&gt; using &lt;code&gt;pip install sqlite-scanner&lt;/code&gt; and then run &lt;code&gt;sqlite-scanner&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;To get a permanent copy with &lt;code&gt;uv&lt;/code&gt; use &lt;code&gt;uv tool install sqlite-scanner&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id="how-the-python-package-works"&gt;How the Python package works&lt;/h4&gt;
&lt;p&gt;The reason this is worth doing is that &lt;code&gt;pip&lt;/code&gt;, &lt;code&gt;uv&lt;/code&gt; and &lt;a href="https://pypi.org/"&gt;PyPI&lt;/a&gt; will work together to identify the correct compiled binary for your operating system and architecture.&lt;/p&gt;
&lt;p&gt;This is driven by file names. If you visit &lt;a href="https://pypi.org/project/sqlite-scanner/#files"&gt;the PyPI downloads for sqlite-scanner&lt;/a&gt; you'll see the following files:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-win_arm64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-win_amd64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-musllinux_1_2_x86_64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-musllinux_1_2_aarch64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-manylinux_2_17_x86_64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-manylinux_2_17_aarch64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-macosx_11_0_arm64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-macosx_10_9_x86_64.whl&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When I run &lt;code&gt;pip install sqlite-scanner&lt;/code&gt; or &lt;code&gt;uvx sqlite-scanner&lt;/code&gt; on my Apple Silicon Mac laptop Python's packaging magic ensures I get that &lt;code&gt;macosx_11_0_arm64.whl&lt;/code&gt; variant.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://tools.simonwillison.net/zip-wheel-explorer?url=https%3A%2F%2Ffiles.pythonhosted.org%2Fpackages%2F88%2Fb1%2F17a716635d2733fec53ba0a8267f85bd6b6cf882c6b29301bc711fba212c%2Fsqlite_scanner-0.1.1-py3-none-macosx_11_0_arm64.whl#sqlite_scanner/__init__.py"&gt;what's in the wheel&lt;/a&gt;, which is a zip file with a &lt;code&gt;.whl&lt;/code&gt; extension.&lt;/p&gt;
&lt;p&gt;In addition to the &lt;code&gt;bin/sqlite-scanner&lt;/code&gt; the most important file is &lt;code&gt;sqlite_scanner/__init__.py&lt;/code&gt; which includes the following:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;get_binary_path&lt;/span&gt;():
    &lt;span class="pl-s"&gt;"""Return the path to the bundled binary."""&lt;/span&gt;
    &lt;span class="pl-s1"&gt;binary&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;os&lt;/span&gt;.&lt;span class="pl-c1"&gt;path&lt;/span&gt;.&lt;span class="pl-c1"&gt;join&lt;/span&gt;(&lt;span class="pl-s1"&gt;os&lt;/span&gt;.&lt;span class="pl-c1"&gt;path&lt;/span&gt;.&lt;span class="pl-c1"&gt;dirname&lt;/span&gt;(&lt;span class="pl-s1"&gt;__file__&lt;/span&gt;), &lt;span class="pl-s"&gt;"bin"&lt;/span&gt;, &lt;span class="pl-s"&gt;"sqlite-scanner"&lt;/span&gt;)
 
    &lt;span class="pl-c"&gt;# Ensure binary is executable on Unix&lt;/span&gt;
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;sys&lt;/span&gt;.&lt;span class="pl-c1"&gt;platform&lt;/span&gt; &lt;span class="pl-c1"&gt;!=&lt;/span&gt; &lt;span class="pl-s"&gt;"win32"&lt;/span&gt;:
        &lt;span class="pl-s1"&gt;current_mode&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;os&lt;/span&gt;.&lt;span class="pl-c1"&gt;stat&lt;/span&gt;(&lt;span class="pl-s1"&gt;binary&lt;/span&gt;).&lt;span class="pl-c1"&gt;st_mode&lt;/span&gt;
        &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-c1"&gt;not&lt;/span&gt; (&lt;span class="pl-s1"&gt;current_mode&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;amp;&lt;/span&gt; &lt;span class="pl-s1"&gt;stat&lt;/span&gt;.&lt;span class="pl-c1"&gt;S_IXUSR&lt;/span&gt;):
            &lt;span class="pl-s1"&gt;os&lt;/span&gt;.&lt;span class="pl-c1"&gt;chmod&lt;/span&gt;(&lt;span class="pl-s1"&gt;binary&lt;/span&gt;, &lt;span class="pl-s1"&gt;current_mode&lt;/span&gt; &lt;span class="pl-c1"&gt;|&lt;/span&gt; &lt;span class="pl-s1"&gt;stat&lt;/span&gt;.&lt;span class="pl-c1"&gt;S_IXUSR&lt;/span&gt; &lt;span class="pl-c1"&gt;|&lt;/span&gt; &lt;span class="pl-s1"&gt;stat&lt;/span&gt;.&lt;span class="pl-c1"&gt;S_IXGRP&lt;/span&gt; &lt;span class="pl-c1"&gt;|&lt;/span&gt; &lt;span class="pl-s1"&gt;stat&lt;/span&gt;.&lt;span class="pl-c1"&gt;S_IXOTH&lt;/span&gt;)
 
    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;binary&lt;/span&gt;
 
 
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;main&lt;/span&gt;():
    &lt;span class="pl-s"&gt;"""Execute the bundled binary."""&lt;/span&gt;
    &lt;span class="pl-s1"&gt;binary&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;get_binary_path&lt;/span&gt;()
 
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;sys&lt;/span&gt;.&lt;span class="pl-c1"&gt;platform&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"win32"&lt;/span&gt;:
        &lt;span class="pl-c"&gt;# On Windows, use subprocess to properly handle signals&lt;/span&gt;
        &lt;span class="pl-s1"&gt;sys&lt;/span&gt;.&lt;span class="pl-c1"&gt;exit&lt;/span&gt;(&lt;span class="pl-s1"&gt;subprocess&lt;/span&gt;.&lt;span class="pl-c1"&gt;call&lt;/span&gt;([&lt;span class="pl-s1"&gt;binary&lt;/span&gt;] &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s1"&gt;sys&lt;/span&gt;.&lt;span class="pl-c1"&gt;argv&lt;/span&gt;[&lt;span class="pl-c1"&gt;1&lt;/span&gt;:]))
    &lt;span class="pl-k"&gt;else&lt;/span&gt;:
        &lt;span class="pl-c"&gt;# On Unix, exec replaces the process&lt;/span&gt;
        &lt;span class="pl-s1"&gt;os&lt;/span&gt;.&lt;span class="pl-c1"&gt;execvp&lt;/span&gt;(&lt;span class="pl-s1"&gt;binary&lt;/span&gt;, [&lt;span class="pl-s1"&gt;binary&lt;/span&gt;] &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s1"&gt;sys&lt;/span&gt;.&lt;span class="pl-c1"&gt;argv&lt;/span&gt;[&lt;span class="pl-c1"&gt;1&lt;/span&gt;:])&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;main()&lt;/code&gt; method - also called from &lt;code&gt;sqlite_scanner/__main__.py&lt;/code&gt; - locates the binary and executes it when the Python package itself is executed, using the &lt;code&gt;sqlite-scanner = sqlite_scanner:main&lt;/code&gt; entry point defined in the wheel.&lt;/p&gt;
&lt;h4 id="which-means-we-can-use-it-as-a-dependency"&gt;Which means we can use it as a dependency&lt;/h4&gt;
&lt;p&gt;Using PyPI as a distribution platform for Go binaries feels a tiny bit abusive, albeit &lt;a href="https://simonwillison.net/2022/May/23/bundling-binary-tools-in-python-wheels/"&gt;there is plenty of precedent&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I’ll justify it by pointing out that this means &lt;strong&gt;we can use Go binaries as dependencies&lt;/strong&gt; for other Python packages now.&lt;/p&gt;
&lt;p&gt;That's genuinely useful! It means that any functionality which is available in a cross-platform Go binary can now be subsumed into a Python package. Python is really good at running subprocesses so this opens up a whole world of useful tricks that we can bake into our Python tools.&lt;/p&gt;
&lt;p&gt;To demonstrate this, I built &lt;a href="https://github.com/simonw/datasette-scan"&gt;datasette-scan&lt;/a&gt; - a new Datasette plugin which depends on &lt;code&gt;sqlite-scanner&lt;/code&gt; and then uses that Go binary to scan a folder for SQLite databases and attach them to a Datasette instance.&lt;/p&gt;
&lt;p&gt;Here's how to use that (without even installing anything first, thanks &lt;code&gt;uv&lt;/code&gt;) to explore any SQLite databases in your Downloads folder:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv run --with datasette-scan datasette scan &lt;span class="pl-k"&gt;~&lt;/span&gt;/Downloads&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;If you peek at the code you'll see it &lt;a href="https://github.com/simonw/datasette-scan/blob/1a2b6d1e6b04c8cd05f5676ff7daa877efd99f08/pyproject.toml#L14"&gt;depends on sqlite-scanner&lt;/a&gt; in &lt;code&gt;pyproject.toml&lt;/code&gt; and calls it using &lt;code&gt;subprocess.run()&lt;/code&gt; against &lt;code&gt;sqlite_scanner.get_binary_path()&lt;/code&gt; in its own &lt;a href="https://github.com/simonw/datasette-scan/blob/1a2b6d1e6b04c8cd05f5676ff7daa877efd99f08/datasette_scan/__init__.py#L38-L58"&gt;scan_directories() function&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I've been exploring this pattern for other, non-Go binaries recently - here's &lt;a href="https://github.com/simonw/tools/blob/main/python/livestream-gif.py"&gt;a recent script&lt;/a&gt; that depends on &lt;a href="https://pypi.org/project/static-ffmpeg/"&gt;static-ffmpeg&lt;/a&gt; to ensure that &lt;code&gt;ffmpeg&lt;/code&gt; is available for the script to use.&lt;/p&gt;
&lt;h4 id="building-python-wheels-from-go-packages-with-go-to-wheel"&gt;Building Python wheels from Go packages with go-to-wheel&lt;/h4&gt;
&lt;p&gt;After trying this pattern myself a couple of times I realized it would be useful to have a tool to automate the process.&lt;/p&gt;
&lt;p&gt;I first &lt;a href="https://claude.ai/share/2d9ced56-b3e8-4651-83cc-860b9b419187"&gt;brainstormed with Claude&lt;/a&gt; to check that there was no existing tool to do this. It pointed me to &lt;a href="https://www.maturin.rs/bindings.html#bin"&gt;maturin bin&lt;/a&gt; which helps distribute Rust projects using Python wheels, and &lt;a href="https://github.com/Bing-su/pip-binary-factory"&gt;pip-binary-factory&lt;/a&gt; which bundles all sorts of other projects, but did not identify anything that addressed the exact problem I was looking to solve.&lt;/p&gt;
&lt;p&gt;So I &lt;a href="https://gisthost.github.io/?41f04e4eb823b1ceb888d9a28c2280dd/index.html"&gt;had Claude Code for web build the first version&lt;/a&gt;, then refined the code locally on my laptop with the help of more Claude Code and a little bit of OpenAI Codex too, just to mix things up.&lt;/p&gt;
&lt;p&gt;The full documentation is in the &lt;a href="https://github.com/simonw/go-to-wheel"&gt;simonw/go-to-wheel&lt;/a&gt; repository. I've published that tool to PyPI so now you can run it using:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx go-to-wheel --help&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;sqlite-scanner&lt;/code&gt; package you can &lt;a href="https://pypi.org/project/sqlite-scanner/"&gt;see on PyPI&lt;/a&gt; was built using &lt;code&gt;go-to-wheel&lt;/code&gt; like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx go-to-wheel &lt;span class="pl-k"&gt;~&lt;/span&gt;/dev/sqlite-scanner \
  --set-version-var main.version \
  --version 0.1.1 \
  --readme README.md \
  --author &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Simon Willison&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; \
  --url https://github.com/simonw/sqlite-scanner \
  --description &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Scan directories for SQLite databases&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This created a set of wheels in the &lt;code&gt;dist/&lt;/code&gt; folder. I tested one of them like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv run --with dist/sqlite_scanner-0.1.1-py3-none-macosx_11_0_arm64.whl \
  sqlite-scanner --version&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;When that spat out the correct version number I was confident everything had worked as planned, so I pushed the whole set of wheels to PyPI using &lt;code&gt;twine upload&lt;/code&gt; like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx twine upload dist/&lt;span class="pl-k"&gt;*&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I had to paste in a PyPI API token I had saved previously.&lt;/p&gt;
&lt;h4 id="i-expect-to-use-this-pattern-a-lot"&gt;I expect to use this pattern a lot&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;sqlite-scanner&lt;/code&gt; is very clearly meant as a proof-of-concept for this wider pattern - Python is very much capable of recursively crawling a directory structure looking for files that start with a specific byte prefix on its own!&lt;/p&gt;
&lt;p&gt;That said, I think there's a &lt;em&gt;lot&lt;/em&gt; to be said for this pattern. Go is a great complement to Python - it's fast, compiles to small self-contained binaries, has excellent concurrency support and a rich ecosystem of libraries.&lt;/p&gt;
&lt;p&gt;Go is similar to Python in that it has a strong standard library. Go is particularly good for HTTP tooling - I've built several HTTP proxies in the past using Go's excellent &lt;code&gt;net/http/httputil.ReverseProxy&lt;/code&gt; handler.&lt;/p&gt;
&lt;p&gt;I've also been experimenting with &lt;a href="https://github.com/wazero/wazero"&gt;wazero&lt;/a&gt;, Go's robust and mature zero dependency WebAssembly runtime as part of my ongoing quest for the ideal sandbox for running untrusted code. &lt;a href="https://github.com/simonw/research/tree/main/wasm-repl-cli"&gt;Here's my latest experiment&lt;/a&gt; with that library.&lt;/p&gt;
&lt;p&gt;Being able to seamlessly integrate Go binaries into Python projects without the end user having to think about Go at all - they &lt;code&gt;pip install&lt;/code&gt; and everything Just Works - feels like a valuable addition to my toolbox.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/go"&gt;go&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/packaging"&gt;packaging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pypi"&gt;pypi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="go"/><category term="packaging"/><category term="projects"/><category term="pypi"/><category term="python"/><category term="sqlite"/><category term="datasette"/><category term="ai-assisted-programming"/><category term="uv"/></entry><entry><title>Introducing the Codex app</title><link href="https://simonwillison.net/2026/Feb/2/introducing-the-codex-app/#atom-tag" rel="alternate"/><published>2026-02-02T19:54:36+00:00</published><updated>2026-02-02T19:54:36+00:00</updated><id>https://simonwillison.net/2026/Feb/2/introducing-the-codex-app/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://openai.com/index/introducing-the-codex-app/"&gt;Introducing the Codex app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
OpenAI just released a new macOS app for their Codex coding agent. I've had a few days of preview access - it's a solid app that provides a nice UI over the capabilities of the Codex CLI agent and adds some interesting new features, most notably first-class support for &lt;a href="https://developers.openai.com/codex/skills"&gt;Skills&lt;/a&gt;, and &lt;a href="https://developers.openai.com/codex/app/automations"&gt;Automations&lt;/a&gt; for running scheduled tasks.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a macOS desktop application with a dark sidebar and light main content area. Left sidebar shows navigation items &amp;quot;New thread&amp;quot;, &amp;quot;Automations&amp;quot;, &amp;quot;Skills&amp;quot;, and a &amp;quot;Threads&amp;quot; section containing two project folders: &amp;quot;local-codex-scratch&amp;quot; with tasks &amp;quot;Reply to greeting task 2h&amp;quot; and &amp;quot;List Codex.app contents 3h&amp;quot;, and &amp;quot;shot-scraper&amp;quot; with tasks &amp;quot;Validate tests on dependency... 3h&amp;quot;, &amp;quot;Document shot-scraper CLI usage 18h&amp;quot; (highlighted), and &amp;quot;Run this: uv run pytest -x 1d&amp;quot;. Bottom left shows &amp;quot;Personal&amp;quot;. Top bar displays &amp;quot;Document shot-scraper CLI usage shot-scraper&amp;quot; with &amp;quot;Open&amp;quot; and &amp;quot;Commit&amp;quot; dropdown buttons. Main content shows a conversation: user message &amp;quot;Build a skill describing how to use the shot-scraper CLI tool&amp;quot; followed by assistant responses: &amp;quot;I'll use the skill-creator skill, scan the repo for existing shot-scraper docs/commands, then draft a concise skill in this workspace and sanity-check it.&amp;quot; then &amp;quot;I've confirmed the skill-creator scripts exist. Next I'll inspect the CLI entrypoint and docs, then scaffold a skill under skills/ in this repo.&amp;quot; then &amp;quot;I've scaffolded skills/shot-scraper-cli/. I'll now replace the template SKILL.md with a task-focused skill and add a compact CLI reference under references/.&amp;quot; then &amp;quot;I'll run the skill packager/validator next to ensure the new skill structure is valid.&amp;quot; then &amp;quot;Done — I created and packaged the skill. Key outputs: • Skill definition: SKILL.md • Compact reference: cli-reference.md • Packaged skill: shot-scraper-cli.skill&amp;quot;. Bottom shows input field with placeholder &amp;quot;Ask for follow-up changes&amp;quot;, &amp;quot;+ Custom Medium&amp;quot; dropdown, and &amp;quot;Local&amp;quot; and &amp;quot;main&amp;quot; branch indicators." src="https://static.simonwillison.net/static/2026/codex-app.jpg" /&gt;&lt;/p&gt;
&lt;p&gt;The app is built with Electron and Node.js. Automations track their state in a SQLite database - here's what that looks like if you explore it with &lt;code&gt;uvx datasette ~/.codex/sqlite/codex-dev.db&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Database schema documentation on light gray background showing three tables: &amp;quot;automation_runs&amp;quot; (teal underlined link) with italic columns &amp;quot;thread_id, automation_id, status, read_at, thread_title, source_cwd, inbox_title, inbox_summary, created_at, updated_at, archived_user_message, archived_assistant_message, archived_reason&amp;quot; and &amp;quot;1 row&amp;quot;; &amp;quot;automations&amp;quot; (teal underlined link) with italic columns &amp;quot;id, name, prompt, status, next_run_at, last_run_at, cwds, rrule, created_at, updated_at&amp;quot; and &amp;quot;1 row&amp;quot;; &amp;quot;inbox_items&amp;quot; (teal underlined link) with italic columns &amp;quot;id, title, description, thread_id, read_at, created_at&amp;quot; and &amp;quot;0 rows&amp;quot;." src="https://static.simonwillison.net/static/2026/codex-dev-sqlite.jpg" /&gt;&lt;/p&gt;
&lt;p&gt;Here’s an interactive copy of that database &lt;a href="https://lite.datasette.io/?url=https%3A%2F%2Fgist.githubusercontent.com%2Fsimonw%2F274c4ecfaf959890011810e6881864fe%2Fraw%2F51fdf25c9426b76e9693ccc0d9254f64ceeef819%2Fcodex-dev.db#/codex-dev"&gt;in Datasette Lite&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The announcement gives us a hint at some usage numbers for Codex overall - the holiday spike is notable:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Since the launch of GPT‑5.2-Codex in mid-December, overall Codex usage has doubled, and in the past month, more than a million developers have used Codex.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Automations are currently restricted in that they can only run when your laptop is powered on. OpenAI promise that cloud-based automations are coming soon, which will resolve this limitation.&lt;/p&gt;
&lt;p&gt;They chose Electron so they could target other operating systems in the future, with Windows “&lt;a href="https://news.ycombinator.com/item?id=46859054#46859673"&gt;coming very soon&lt;/a&gt;”. OpenAI’s Alexander Embiricos noted &lt;a href="https://news.ycombinator.com/item?id=46859054#46859693"&gt;on the Hacker News thread&lt;/a&gt; that:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;it's taking us some time to get really solid sandboxing working on Windows, where there are fewer OS-level primitives for it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Like Claude Code, Codex is really a general agent harness disguised as a tool for programmers. OpenAI acknowledge that here:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Codex is built on a simple premise: everything is controlled by code. The better an agent is at reasoning about and producing code, the more capable it becomes across all forms of technical and knowledge work. [...] We’ve focused on making Codex the best coding agent, which has also laid the foundation for it to become a strong agent for a broad range of knowledge work tasks that extend beyond writing code.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Claude Code had to &lt;a href="https://simonwillison.net/2026/Jan/12/claude-cowork/"&gt;rebrand to Cowork&lt;/a&gt; to better cover the general knowledge work case. OpenAI can probably get away with keeping the Codex name for both.&lt;/p&gt;
&lt;p&gt;OpenAI have made Codex available to free and &lt;a href="https://simonwillison.net/2026/Jan/16/chatgpt-ads/"&gt;Go&lt;/a&gt; plans for "a limited time" (update: Sam Altman &lt;a href="https://x.com/sama/status/2018437537103269909"&gt;says two months&lt;/a&gt;) during which they are also doubling the rate limits for paying users.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sandboxing"&gt;sandboxing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/electron"&gt;electron&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/openai"&gt;openai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-agents"&gt;ai-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/codex"&gt;codex&lt;/a&gt;&lt;/p&gt;



</summary><category term="sandboxing"/><category term="sqlite"/><category term="ai"/><category term="datasette"/><category term="electron"/><category term="openai"/><category term="generative-ai"/><category term="llms"/><category term="ai-agents"/><category term="coding-agents"/><category term="codex"/></entry><entry><title>sqlite-ast 0.1a0</title><link href="https://simonwillison.net/2026/Jan/30/sqlite-ast-2/#atom-tag" rel="alternate"/><published>2026-01-30T06:12:45+00:00</published><updated>2026-01-30T06:12:45+00:00</updated><id>https://simonwillison.net/2026/Jan/30/sqlite-ast-2/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/sqlite-ast/releases/tag/0.1a0"&gt;sqlite-ast 0.1a0&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;I wanted a Python library that could parse SQLite SELECT statements, so I vibe coded this one up based on a specification I reverse-engineered from SQLite's own parser behavior.&lt;/p&gt;
&lt;p&gt;There's an &lt;a href="https://tools.simonwillison.net/sqlite-ast"&gt;interactive playground here&lt;/a&gt; for trying it out in the browser (via Pyodide).&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vibe-coding"&gt;vibe-coding&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="python"/><category term="sqlite"/><category term="vibe-coding"/></entry><entry><title>The Design &amp; Implementation of Sprites</title><link href="https://simonwillison.net/2026/Jan/15/the-design-implementation-of-sprites/#atom-tag" rel="alternate"/><published>2026-01-15T16:08:27+00:00</published><updated>2026-01-15T16:08:27+00:00</updated><id>https://simonwillison.net/2026/Jan/15/the-design-implementation-of-sprites/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://fly.io/blog/design-and-implementation/"&gt;The Design &amp;amp; Implementation of Sprites&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I &lt;a href="https://simonwillison.net/2026/Jan/9/sprites-dev/"&gt;wrote about Sprites last week&lt;/a&gt;. Here's Thomas Ptacek from Fly with the insider details on how they work under the hood.&lt;/p&gt;
&lt;p&gt;I like this framing of them as "disposable computers":&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Sprites are ball-point disposable computers. Whatever mark you mean to make, we’ve rigged it so you’re never more than a second or two away from having a Sprite to do it with.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I've noticed that new Fly Machines can take a while (up to around a minute) to provision. Sprites solve that by keeping warm pools of unused machines in multiple regions, which is enabled by them all using the same container:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Now, today, under the hood, Sprites are still Fly Machines. But they all run from a standard container. Every physical worker knows exactly what container the next Sprite is going to start with, so it’s easy for us to keep pools of “empty” Sprites standing by. The result: a Sprite create doesn’t have any heavy lifting to do; it’s basically just doing the stuff we do when we start a Fly Machine.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The most interesting detail is how the persistence layer works. Sprites only charge you for data you have written that differs from the base image and provide ~300ms checkpointing and restores - it turns out that's power by a custom filesystem on top of S3-compatible storage coordinated by Litestream-replicated local SQLite metadata:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;We still exploit NVMe, but not as the root of storage. Instead, it’s a read-through cache for a blob on object storage. S3-compatible object stores are the most trustworthy storage technology we have. I can feel my blood pressure dropping just typing the words “Sprites are backed by object storage.” [...]&lt;/p&gt;
&lt;p&gt;The Sprite storage stack is organized around the JuiceFS model (in fact, we currently use a very hacked-up JuiceFS, with a rewritten SQLite metadata backend). It works by splitting storage into data (“chunks”) and metadata (a map of where the “chunks” are). Data chunks live on object stores; metadata lives in fast local storage. In our case, that metadata store is &lt;a href="https://litestream.io"&gt;kept durable with Litestream&lt;/a&gt;. Nothing depends on local storage.&lt;/p&gt;
&lt;/blockquote&gt;

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/tqbf/status/2011823480673624434"&gt;@tqbf&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sandboxing"&gt;sandboxing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/thomas-ptacek"&gt;thomas-ptacek&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/software-architecture"&gt;software-architecture&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/fly"&gt;fly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/litestream"&gt;litestream&lt;/a&gt;&lt;/p&gt;



</summary><category term="sandboxing"/><category term="sqlite"/><category term="thomas-ptacek"/><category term="software-architecture"/><category term="fly"/><category term="litestream"/></entry><entry><title>The most popular blogs of Hacker News in 2025</title><link href="https://simonwillison.net/2026/Jan/2/most-popular-blogs-of-hacker-news/#atom-tag" rel="alternate"/><published>2026-01-02T19:10:43+00:00</published><updated>2026-01-02T19:10:43+00:00</updated><id>https://simonwillison.net/2026/Jan/2/most-popular-blogs-of-hacker-news/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://refactoringenglish.com/blog/2025-hn-top-5/"&gt;The most popular blogs of Hacker News in 2025&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Michael Lynch maintains &lt;a href="https://refactoringenglish.com/tools/hn-popularity/"&gt;HN Popularity Contest&lt;/a&gt;, a site that tracks personal blogs on Hacker News and scores them based on how well they perform on that platform.&lt;/p&gt;
&lt;p&gt;The engine behind the project is the &lt;a href="https://github.com/mtlynch/hn-popularity-contest-data/blob/master/data/domains-meta.csv"&gt;domain-meta.csv&lt;/a&gt; CSV on GiHub, a hand-curated list of known personal blogs with author and bio and tag metadata, which Michael uses to separate out personal blog posts from other types of content.&lt;/p&gt;
&lt;p&gt;I came top of the rankings in 2023, 2024 and 2025 but I'm listed &lt;a href="https://refactoringenglish.com/tools/hn-popularity/"&gt;in third place&lt;/a&gt; for all time behind Paul Graham and Brian Krebs.&lt;/p&gt;
&lt;p&gt;I dug around in the browser inspector and was delighted to find that the data powering the site is served with open CORS headers, which means you can easily explore it with external services like Datasette Lite.&lt;/p&gt;
&lt;p&gt;Here's a convoluted window function query Claude Opus 4.5 &lt;a href="https://claude.ai/share/8e1cb294-0ff0-4d5b-b83f-58e4c7fdb0d2"&gt;wrote for me&lt;/a&gt; which, for a given domain, shows where that domain ranked for each year since it first appeared in the dataset:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s"&gt;with yearly_scores as (&lt;/span&gt;
&lt;span class="pl-s"&gt;  select &lt;/span&gt;
&lt;span class="pl-s"&gt;    domain,&lt;/span&gt;
&lt;span class="pl-s"&gt;    strftime('%Y', date) as year,&lt;/span&gt;
&lt;span class="pl-s"&gt;    sum(score) as total_score,&lt;/span&gt;
&lt;span class="pl-s"&gt;    count(distinct date) as days_mentioned&lt;/span&gt;
&lt;span class="pl-s"&gt;  from "hn-data"&lt;/span&gt;
&lt;span class="pl-s"&gt;  group by domain, strftime('%Y', date)&lt;/span&gt;
&lt;span class="pl-s"&gt;),&lt;/span&gt;
&lt;span class="pl-s"&gt;ranked as (&lt;/span&gt;
&lt;span class="pl-s"&gt;  select &lt;/span&gt;
&lt;span class="pl-s"&gt;    domain,&lt;/span&gt;
&lt;span class="pl-s"&gt;    year,&lt;/span&gt;
&lt;span class="pl-s"&gt;    total_score,&lt;/span&gt;
&lt;span class="pl-s"&gt;    days_mentioned,&lt;/span&gt;
&lt;span class="pl-s"&gt;    rank() over (partition by year order by total_score desc) as rank&lt;/span&gt;
&lt;span class="pl-s"&gt;  from yearly_scores&lt;/span&gt;
&lt;span class="pl-s"&gt;)&lt;/span&gt;
&lt;span class="pl-s"&gt;select &lt;/span&gt;
&lt;span class="pl-s"&gt;  r.year,&lt;/span&gt;
&lt;span class="pl-s"&gt;  r.total_score,&lt;/span&gt;
&lt;span class="pl-s"&gt;  r.rank,&lt;/span&gt;
&lt;span class="pl-s"&gt;  r.days_mentioned&lt;/span&gt;
&lt;span class="pl-s"&gt;from ranked r&lt;/span&gt;
&lt;span class="pl-s"&gt;where r.domain = :domain&lt;/span&gt;
&lt;span class="pl-s"&gt;  and r.year &amp;gt;= (&lt;/span&gt;
&lt;span class="pl-s"&gt;    select min(strftime('%Y', date)) &lt;/span&gt;
&lt;span class="pl-s"&gt;    from "hn-data"&lt;/span&gt;
&lt;span class="pl-s"&gt;    where domain = :domain&lt;/span&gt;
&lt;span class="pl-s"&gt;  )&lt;/span&gt;
&lt;span class="pl-s"&gt;order by r.year desc&lt;/span&gt;&lt;/pre&gt;

&lt;p&gt;(I just noticed that the last &lt;code&gt;and r.year &amp;gt;= (&lt;/code&gt; clause isn't actually needed here.)&lt;/p&gt;
&lt;p&gt;My &lt;a href="https://lite.datasette.io/?csv=https://hn-popularity.cdn.refactoringenglish.com/hn-data.csv#/data?sql=with+yearly_scores+as+%28%0A++select+%0A++++domain%2C%0A++++strftime%28%27%25Y%27%2C+date%29+as+year%2C%0A++++sum%28score%29+as+total_score%2C%0A++++count%28distinct+date%29+as+days_mentioned%0A++from+%22hn-data%22%0A++group+by+domain%2C+strftime%28%27%25Y%27%2C+date%29%0A%29%2C%0Aranked+as+%28%0A++select+%0A++++domain%2C%0A++++year%2C%0A++++total_score%2C%0A++++days_mentioned%2C%0A++++rank%28%29+over+%28partition+by+year+order+by+total_score+desc%29+as+rank%0A++from+yearly_scores%0A%29%0Aselect+%0A++r.year%2C%0A++r.total_score%2C%0A++r.rank%2C%0A++r.days_mentioned%0Afrom+ranked+r%0Awhere+r.domain+%3D+%3Adomain%0A++and+r.year+%3E%3D+%28%0A++++select+min%28strftime%28%27%25Y%27%2C+date%29%29+%0A++++from+%22hn-data%22%0A++++where+domain+%3D+%3Adomain%0A++%29%0Aorder+by+r.year+desc&amp;amp;domain=simonwillison.net"&gt;simonwillison.net results&lt;/a&gt; show me ranked 3rd in 2022, 30th in 2021 and 85th back in 2007 - though I expect there are many personal blogs from that year which haven't yet been manually added to Michael's list.&lt;/p&gt;
&lt;p&gt;Also useful is that every domain gets its own CORS-enabled CSV file with details of the actual Hacker News submitted from that domain, e.g. &lt;code&gt;https://hn-popularity.cdn.refactoringenglish.com/domains/simonwillison.net.csv&lt;/code&gt;. Here's &lt;a href="https://lite.datasette.io/?csv=https://hn-popularity.cdn.refactoringenglish.com/domains/simonwillison.net.csv#/data/simonwillison"&gt;that one in Datasette Lite&lt;/a&gt;.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://news.ycombinator.com/item?id=46465819"&gt;Hacker News&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/hacker-news"&gt;hacker-news&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sql"&gt;sql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-lite"&gt;datasette-lite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cors"&gt;cors&lt;/a&gt;&lt;/p&gt;



</summary><category term="hacker-news"/><category term="sql"/><category term="sqlite"/><category term="datasette"/><category term="datasette-lite"/><category term="cors"/></entry><entry><title>Quoting D. Richard Hipp</title><link href="https://simonwillison.net/2025/Dec/29/d-richard-hipp/#atom-tag" rel="alternate"/><published>2025-12-29T21:51:49+00:00</published><updated>2025-12-29T21:51:49+00:00</updated><id>https://simonwillison.net/2025/Dec/29/d-richard-hipp/#atom-tag</id><summary type="html">
    &lt;blockquote cite="https://sigmodrecord.org/publications/sigmodRecord/1906/pdfs/06_Profiles_Hipp.pdf"&gt;&lt;p&gt;But once we got that and got this aviation grade testing in place, the number of bugs just dropped to a trickle. Now we still do have bugs but the aviation grade testing allows us to move fast, which is important because in this business you either move fast or you're disrupted. So, we're able to make major changes to the structure of the code that we deliver and be confident that we're not breaking things because we had these intense tests. Probably half the time we spend is actually writing new tests, we're constantly writing new tests. And over the 17-year history, we have amassed a huge suite of tests which we run constantly.&lt;/p&gt;
&lt;p&gt;Other database engines don't do this; don't have this
level of testing. But they're still high quality, I mean, I
noticed in particular, PostgreSQL is a very high-quality database engine, they don't have many bugs. I went to the PostgreSQL and ask them “how do you prevent the bugs”? We talked about this for a while. What I came away with was they've got a very elaborate peer review process, and if they've got code that has worked for 10 years they just don't mess with it, leave it alone, it
works. Whereas we change our code fearlessly, and we have a much smaller team and we don't have the peer review process.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p class="cite"&gt;&amp;mdash; &lt;a href="https://sigmodrecord.org/publications/sigmodRecord/1906/pdfs/06_Profiles_Hipp.pdf"&gt;D. Richard Hipp&lt;/a&gt;, ACM SIGMOD Record, June 2019 (PDF)&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/testing"&gt;testing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/d-richard-hipp"&gt;d-richard-hipp&lt;/a&gt;&lt;/p&gt;



</summary><category term="postgresql"/><category term="sqlite"/><category term="testing"/><category term="d-richard-hipp"/></entry></feed>