<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: annotated-release-notes</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/annotated-release-notes.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2026-08-22T17:01:16+00:00</updated><author><name>Simon Willison</name></author><entry><title>llm 0.33</title><link href="https://simonwillison.net/2026/Aug/22/llm/" rel="alternate"/><published>2026-08-22T17:01:16+00:00</published><updated>2026-08-22T17:01:16+00:00</updated><id>https://simonwillison.net/2026/Aug/22/llm/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/llm/releases/tag/0.33"&gt;llm 0.33&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;My highlights from this release:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Upgraded to the OpenAI Python library 3.x and switched the HTTP client dependency from &lt;code&gt;httpx&lt;/code&gt; to &lt;code&gt;httpx2&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/1608"&gt;#1608&lt;/a&gt;, &lt;a href="https://github.com/simonw/llm/pull/1631"&gt;#1631&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I shipped a quick &lt;a href="https://simonwillison.net/2026/Aug/21/llm/"&gt;0.32.1 fix&lt;/a&gt; for this yesterday, but this is the more comprehensive fix.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;llm embed&lt;/code&gt; and &lt;code&gt;llm embed-multi&lt;/code&gt; now accept &lt;code&gt;--key&lt;/code&gt;. The Python &lt;code&gt;EmbeddingModel.embed()&lt;/code&gt;, &lt;code&gt;EmbeddingModel.embed_multi()&lt;/code&gt;, &lt;code&gt;Collection.embed()&lt;/code&gt; and &lt;code&gt;Collection.embed_multi()&lt;/code&gt; methods accept &lt;code&gt;key=&lt;/code&gt; too, passing the resolved per-call key to embedding plugins without changing shared model state. Existing plugins that read &lt;code&gt;self.key&lt;/code&gt; continue to work through a compatibility fallback. Thanks, &lt;a href="https://github.com/ChrisJr404"&gt;ChrisJr404&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/757"&gt;#757&lt;/a&gt;, &lt;a href="https://github.com/simonw/llm/pull/1620"&gt;#1620&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The embedding models now use the same pattern for keys that regular LLM models do.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;llm prompt -t/--template&lt;/code&gt; can now be repeated to combine templates in order. This allows model configuration and options from one template to be used with a prompt from another.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This unlocks a neat pattern where you can create templates that package a model with a set of default options:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -m gpt-5.6-luna -o reasoning_effort high --save lhigh
llm "Generate an SVG of a pelican riding a bicycle" --save pelican
# Combine and run the templates
llm -t lhigh -t pelican
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Reasoning-capable Responses API models now support a &lt;code&gt;reasoning_summary&lt;/code&gt; option with &lt;code&gt;auto&lt;/code&gt;, &lt;code&gt;concise&lt;/code&gt;, and &lt;code&gt;detailed&lt;/code&gt; values. This can be used with &lt;a href="https://llm.datasette.io/en/stable/other-models.html#openai-endpoint"&gt;llm openai endpoint --responses&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/1600"&gt;#1600&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is particularly useful for exercising different models that provide their own imitation of the OpenAI Responses API.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="annotated-release-notes"/><category term="llm"/></entry><entry><title>sqlite-utils 4.1</title><link href="https://simonwillison.net/2026/Jul/11/sqlite-utils/" 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/</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/" 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/</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/" 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/</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/" 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/</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/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/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="ai"/><category term="sqlite-utils"/><category term="annotated-release-notes"/><category term="generative-ai"/><category term="llms"/><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/" 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/</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>datasette 1.0a34</title><link href="https://simonwillison.net/2026/Jun/16/datasette/" rel="alternate"/><published>2026-06-16T21:31:24+00:00</published><updated>2026-06-16T21:31:24+00:00</updated><id>https://simonwillison.net/2026/Jun/16/datasette/</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.0a34"&gt;datasette 1.0a34&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;Quoting the release notes:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The big feature in this alpha is tools to insert, edit and delete rows within the Datasette interface. These features are available on table pages, and edit and delete are also available as action items on the row page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;click-to-play&gt;&lt;a href="https://static.simonwillison.net/static/2026/datasette-edit.gif"&gt;&lt;img src="https://static.simonwillison.net/static/2026/datasette-edit-first-frame.gif" /&gt;&lt;/a&gt;&lt;/click-to-play&gt;&lt;/p&gt;

&lt;p&gt;The inspiration for this feature - which is &lt;em&gt;long&lt;/em&gt; overdue - was &lt;a href="https://agent.datasette.io/"&gt;Datasette Agent&lt;/a&gt;. I added &lt;a href="https://simonwillison.net/2026/Jun/15/datasette-agent/"&gt;SQL write support&lt;/a&gt; to that the other day which highlighted how absurd it was that you could insert and edit ties via the chat interface but not in the regular Datasette UI!&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/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="datasette"/><category term="annotated-release-notes"/></entry><entry><title>datasette-agent 0.3a0</title><link href="https://simonwillison.net/2026/Jun/15/datasette-agent/" rel="alternate"/><published>2026-06-15T17:19:27+00:00</published><updated>2026-06-15T17:19:27+00:00</updated><id>https://simonwillison.net/2026/Jun/15/datasette-agent/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/datasette/datasette-agent/releases/tag/0.3a0"&gt;datasette-agent 0.3a0&lt;/a&gt;&lt;/p&gt;
        &lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New tool, &lt;code&gt;execute_write_sql&lt;/code&gt;, which requests user approval and then writes to a database - taking user permissions into account. &lt;a href="https://github.com/datasette/datasette-agent/issues/27"&gt;#27&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I added a mechanism for asking user approval in &lt;a href="https://simonwillison.net/2026/Jun/10/datasette-agent/"&gt;datasette agent 0.2a0&lt;/a&gt;. The new &lt;code&gt;execute_write_sql&lt;/code&gt; tool can now prompt the user for all kinds of useful operations. Here's an example where I add some pelican sightings to my &lt;code&gt;pelican_sightings&lt;/code&gt; table:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a chat interface showing a write SQL confirmation dialog. User message (blue bubble): &amp;quot;I saw 4 pelicans flying over the harbor&amp;quot;. Collapsed tool section: &amp;quot;► Tool: execute_write_sql&amp;quot;. A yellow-bordered confirmation card reads: &amp;quot;Confirm write SQL batch / Database: pelicans / Statements execute in order. If one statement fails, later statements will not be executed. / Statement 1 / INSERT INTO pelican_sightings (number_of_pelicans, notes) VALUES (:number_of_pelicans, :notes); / number_of_pelicans 4 / notes Flying over the harbor&amp;quot;. A table with columns &amp;quot;Operation, Database, Table, Required permissions&amp;quot; shows row: &amp;quot;insert, pelicans, pelican_sightings&amp;quot; with permission buttons &amp;quot;insert-row&amp;quot;, &amp;quot;update-row&amp;quot;, &amp;quot;delete-row&amp;quot;. Below: &amp;quot;Execute 1 write SQL statement against database 'pelicans'? / Asked by tool: execute_write_sql&amp;quot; with &amp;quot;Yes&amp;quot; (blue) and &amp;quot;No&amp;quot; (gray) buttons." src="https://static.simonwillison.net/static/2026/i-saw-pelicans-agent.jpg" /&gt;&lt;/p&gt;
&lt;p&gt;The new version also enhances the &lt;code&gt;datasette agent chat&lt;/code&gt; terminal mode to support approvals, and adds several new options including &lt;code&gt;--unsafe&lt;/code&gt; mode for auto-approving them:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;datasette agent chat&lt;/code&gt; can execute tools that require user approval. &lt;a href="https://github.com/datasette/datasette-agent/issues/30"&gt;#30&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Three new options for &lt;code&gt;datasette agent chat&lt;/code&gt; - &lt;code&gt;--root&lt;/code&gt; to run as root, &lt;code&gt;--yes&lt;/code&gt; to approve all ask user questions, and &lt;code&gt;--unsafe&lt;/code&gt; for both.&lt;/li&gt;
&lt;li&gt;Tools can now provide plain text alternatives to HTML, for display in the &lt;code&gt;datasette agent chat&lt;/code&gt; CLI. &lt;a href="https://github.com/datasette/datasette-agent/issues/31"&gt;#31&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;code&gt;datasette agent chat content.db -m gpt-5.5 --unsafe&lt;/code&gt; command can now be used to chat directly with a specific database &lt;em&gt;and&lt;/em&gt; directly modify it through prompts like "create a notes table", "add a note about X" etc.&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/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/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/llm-tool-use"&gt;llm-tool-use&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="ai"/><category term="datasette"/><category term="annotated-release-notes"/><category term="generative-ai"/><category term="llms"/><category term="llm-tool-use"/><category term="datasette-agent"/></entry><entry><title>datasette 1.0a33</title><link href="https://simonwillison.net/2026/Jun/11/datasette/" rel="alternate"/><published>2026-06-11T15:26:49+00:00</published><updated>2026-06-11T15:26:49+00:00</updated><id>https://simonwillison.net/2026/Jun/11/datasette/</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.0a33"&gt;datasette 1.0a33&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;This alpha is a significant step on the road to a stable 1.0, finally extending the &lt;code&gt;?_extra=&lt;/code&gt; pattern I introduced &lt;a href="https://docs.datasette.io/en/1.0a3/changelog.html#a3-2023-08-09"&gt;in Datasette 1.0a3&lt;/a&gt; to cover queries and rows in addition to tables. That pattern is also &lt;a href="https://docs.datasette.io/en/latest/json_api.html#expanding-json-responses"&gt;now documented&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;I wrote a whole lot more about the new release on the Datasette project blog: &lt;strong&gt;&lt;a href="http://datasette.io/blog/2026/api-extras/"&gt;Datasette 1.0a33 with JSON extras in the API&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Because API explorer tools are almost free to build now I had Claude Fable 5 in Claude Code (for &lt;a href="https://gist.github.com/simonw/d8bf1a8f36e28fbd595cede946e0ab6d"&gt;the plan&lt;/a&gt;) and GPT-5.5 xhigh in Codex Desktop (for &lt;a href="https://gist.github.com/simonw/12d5e09797072a6807d7b9cfcc8ff6b7"&gt;the implementation&lt;/a&gt;) build me this &lt;a href="https://tools.simonwillison.net/datasette-extras-explorer"&gt;custom extras API explorer&lt;/a&gt; to help demonstrate the feature:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a web application titled &amp;quot;Datasette extras explorer&amp;quot;. A URL input field contains https://latest.datasette.io/fixtures/facetable.json with a teal Explore button next to it. Below, a left panel labeled EXTRAS (30) lists checkboxes: all_columns - All columns in the table, regardless of _col/_nocol filtering; column_types - Column type assignments for this table; columns (checked) - Column names returned by this query; count - Total count of rows matching these filters; count_sql - SQL query used to calculate the total count; custom_table_templates - Custom template names considered for this table; database - Database name; database_color - Color assigned to the database. A right panel labeled RESPONSE shows GET /fixtures/fac… with Copy JSON and Copy URL buttons, then a dark JSON viewer showing 200 - 9.9 KB - 114ms and JSON: &amp;quot;ok&amp;quot;: true, &amp;quot;next&amp;quot;: null, &amp;quot;columns&amp;quot;: (highlighted array) &amp;quot;pk&amp;quot;, &amp;quot;created&amp;quot;, &amp;quot;planet_int&amp;quot;, &amp;quot;on_earth&amp;quot;, &amp;quot;state&amp;quot;, &amp;quot;_city_id&amp;quot;, &amp;quot;_neighborhood&amp;quot;, &amp;quot;tags&amp;quot;, &amp;quot;complex_array&amp;quot;, &amp;quot;distinct_some_null&amp;quot;, &amp;quot;n&amp;quot;, &amp;quot;rows&amp;quot;: list of objects." src="https://static.simonwillison.net/static/2026/extras-explorer.png" /&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/datasette"&gt;datasette&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="datasette"/><category term="annotated-release-notes"/><category term="ai-assisted-programming"/></entry><entry><title>datasette 1.0a32</title><link href="https://simonwillison.net/2026/May/31/datasette/" rel="alternate"/><published>2026-05-31T23:23:38+00:00</published><updated>2026-05-31T23:23:38+00:00</updated><id>https://simonwillison.net/2026/May/31/datasette/</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.0a32"&gt;datasette 1.0a32&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;A minor bugfix release. Fixes a bug with &lt;code&gt;INSERT ... RETURNING&lt;/code&gt; queries via the &lt;a href="https://datasette.io/blog/2026/sql-write-queries/"&gt;new /db/-/execute-write endpoint&lt;/a&gt; and a bunch of &lt;a href="https://docs.datasette.io/en/latest/settings.html#setting-base-url"&gt;base_url&lt;/a&gt; issues which showed up when I was &lt;a href="https://simonwillison.net/2026/May/30/pyodide-asgi-browser/"&gt;experimenting with Service Workers&lt;/a&gt; yesterday.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &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="datasette"/><category term="annotated-release-notes"/></entry><entry><title>datasette 1.0a31</title><link href="https://simonwillison.net/2026/May/29/datasette/" 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/</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>datasette 1.0a30</title><link href="https://simonwillison.net/2026/May/24/datasette/" rel="alternate"/><published>2026-05-24T23:52:37+00:00</published><updated>2026-05-24T23:52:37+00:00</updated><id>https://simonwillison.net/2026/May/24/datasette/</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.0a30"&gt;datasette 1.0a30&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;The big new feature in this alpha is a new customizable "Jump to..." menu, described in detail in &lt;a href="https://datasette.io/blog/2026/jump-menu/"&gt;The extensible "Jump to" menu in Datasette 1.0a30&lt;/a&gt; on the Datasette blog. You can try it out by hitting &lt;code&gt;/&lt;/code&gt; on &lt;a href="https://latest.datasette.io/"&gt;latest.datasette.io&lt;/a&gt; - it looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Animated demo - the Jump to menu appears, and as the user types it filters to specific databases and tables and debug options" src="https://static.simonwillison.net/static/2026/menu.gif" /&gt;&lt;/p&gt;
&lt;p&gt;The new &lt;a href="https://docs.datasette.io/en/latest/plugin_hooks.html#jump-items-sql-datasette-actor-request"&gt;jump_items_sql()&lt;/a&gt; plugin hook allows plugins to add their own items to the set that's searched by the plugin.&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/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="datasette"/><category term="annotated-release-notes"/></entry><entry><title>llm 0.32a2</title><link href="https://simonwillison.net/2026/May/12/llm/" rel="alternate"/><published>2026-05-12T17:45:07+00:00</published><updated>2026-05-12T17:45:07+00:00</updated><id>https://simonwillison.net/2026/May/12/llm/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/llm/releases/tag/0.32a2"&gt;llm 0.32a2&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;A bunch of useful stuff in this &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; alpha, but the most important detail is this one:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Most reasoning-capable OpenAI models now use the &lt;a href="https://developers.openai.com/api/reference/responses/overview"&gt;&lt;code&gt;/v1/responses&lt;/code&gt;&lt;/a&gt; endpoint instead of &lt;code&gt;/v1/chat/completions&lt;/code&gt;. This enables interleaved reasoning across tool calls for GPT-5 class models. &lt;a href="https://github.com/simonw/llm/pull/1435"&gt;#1435&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This means you can now see the summarized reasoning tokens when you run prompts against an OpenAI model, displayed in a different color to standard error. Use the &lt;code&gt;-R&lt;/code&gt; or &lt;code&gt;--hide-reasoning&lt;/code&gt; flags if you don't want to see that.&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/ai"&gt;ai&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/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/llm"&gt;llm&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="ai"/><category term="annotated-release-notes"/><category term="openai"/><category term="generative-ai"/><category term="llms"/><category term="llm"/></entry><entry><title>LLM 0.32a0  is a major backwards-compatible refactor</title><link href="https://simonwillison.net/2026/Apr/29/llm/" rel="alternate"/><published>2026-04-29T19:01:47+00:00</published><updated>2026-04-29T19:01:47+00:00</updated><id>https://simonwillison.net/2026/Apr/29/llm/</id><summary type="html">
    &lt;p&gt;I just released &lt;a href="https://llm.datasette.io/en/latest/changelog.html#a0-2026-04-28"&gt;LLM 0.32a0&lt;/a&gt;, an alpha release of my &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; Python library and CLI tool for accessing LLMs, with some consequential changes that I've been working towards for quite a while.&lt;/p&gt;
&lt;p&gt;Previous versions of LLM modeled the world in terms of prompts and responses. Send the model a text prompt, get back a text response.&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;

&lt;span class="pl-s1"&gt;model&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_model&lt;/span&gt;(&lt;span class="pl-s"&gt;"gpt-5.5"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(&lt;span class="pl-s"&gt;"Capital of France?"&lt;/span&gt;)
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;text&lt;/span&gt;())&lt;/pre&gt;
&lt;p&gt;This made sense when I started working on the library back in April 2023. A lot has changed since then!&lt;/p&gt;
&lt;p&gt;LLM provides an abstraction over thousands of different models via its &lt;a href="https://llm.datasette.io/en/stable/plugins/index.html"&gt;plugin system&lt;/a&gt;. The original abstraction - of text input that returns text output - was no longer able to represent everything I needed it to.&lt;/p&gt;
&lt;p&gt;Over time LLM itself has grown &lt;a href="https://simonwillison.net/2024/Oct/29/llm-multi-modal/"&gt;attachments&lt;/a&gt; to handle image, audio, and video input, then &lt;a href="https://simonwillison.net/2025/Feb/28/llm-schemas/"&gt;schemas&lt;/a&gt; for outputting structured JSON, then &lt;a href="https://simonwillison.net/2025/May/27/llm-tools/"&gt;tools&lt;/a&gt; for executing tool calls. Meanwhile LLMs kept evolving, adding reasoning support and the ability to return images and all kinds of other interesting capabilities.&lt;/p&gt;
&lt;p&gt;LLM needs to evolve to better handle the diversity of input and output types that can be processed by today's frontier models.&lt;/p&gt;
&lt;p&gt;The 0.32a0 alpha has two key changes: model inputs can be represented as a sequence of messages, and model responses can be composed of a stream of differently typed parts.&lt;/p&gt;
&lt;h4 id="prompts-as-a-sequence-of-messages"&gt;Prompts as a sequence of messages&lt;/h4&gt;
&lt;p&gt;LLMs accept input as text, but ever since ChatGPT demonstrated the value of a two-way conversational interface, the most common way to prompt them has been to treat that input as a sequence of conversational turns.&lt;/p&gt;
&lt;p&gt;The first turn might look like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user: Capital of France?
assistant: 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(The model then gets to fill out the reply from the assistant.)&lt;/p&gt;
&lt;p&gt;But each subsequent turn needs to replay the entire conversation up to that point, as a sort of screenplay:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user: Capital of France?
assistant: Paris
user: Germany?
assistant:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Most of the JSON APIs from the major vendors follow this pattern. Here's what the above looks like using the OpenAI chat completions API, which has been widely imitated by other providers:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl https://api.openai.com/v1/chat/completions \
  -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Authorization: Bearer &lt;span class="pl-smi"&gt;$OPENAI_API_KEY&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Content-Type: application/json&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  -d &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{&lt;/span&gt;
&lt;span class="pl-s"&gt;    "model": "gpt-5.5",&lt;/span&gt;
&lt;span class="pl-s"&gt;    "messages": [&lt;/span&gt;
&lt;span class="pl-s"&gt;      {&lt;/span&gt;
&lt;span class="pl-s"&gt;        "role": "user",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "content": "Capital of France?"&lt;/span&gt;
&lt;span class="pl-s"&gt;      },&lt;/span&gt;
&lt;span class="pl-s"&gt;      {&lt;/span&gt;
&lt;span class="pl-s"&gt;        "role": "assistant",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "content": "Paris"&lt;/span&gt;
&lt;span class="pl-s"&gt;      },&lt;/span&gt;
&lt;span class="pl-s"&gt;      {&lt;/span&gt;
&lt;span class="pl-s"&gt;        "role": "user",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "content": "Germany?"&lt;/span&gt;
&lt;span class="pl-s"&gt;      }&lt;/span&gt;
&lt;span class="pl-s"&gt;    ]&lt;/span&gt;
&lt;span class="pl-s"&gt;  }&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Prior to 0.32, LLM modeled these as conversations:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;model&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_model&lt;/span&gt;(&lt;span class="pl-s"&gt;"gpt-5.5"&lt;/span&gt;)

&lt;span class="pl-s1"&gt;conversation&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;conversation&lt;/span&gt;()
&lt;span class="pl-s1"&gt;r1&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;conversation&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(&lt;span class="pl-s"&gt;"Capital of France?"&lt;/span&gt;)
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;r1&lt;/span&gt;.&lt;span class="pl-c1"&gt;text&lt;/span&gt;())
&lt;span class="pl-c"&gt;# Outputs "Paris"&lt;/span&gt;

&lt;span class="pl-s1"&gt;r2&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;conversation&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(&lt;span class="pl-s"&gt;"Germany?"&lt;/span&gt;)
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;r2&lt;/span&gt;.&lt;span class="pl-c1"&gt;text&lt;/span&gt;())
&lt;span class="pl-c"&gt;# Outputs "Berlin"&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This worked if you were building a conversation with the model from scratch, but it didn't provide a way to feed in a previous conversation from the start. This made tasks like building an emulation of the OpenAI chat completions API much harder than they should have been.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;llm&lt;/code&gt; CLI tool worked around this through a custom mechanism for persisting and inflating conversations using SQLite, but that never became a stable part of the LLM API - and there are many places you might want to use the Python library without committing to SQLite as the storage layer.&lt;/p&gt;
&lt;p&gt;The new alpha now supports this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;user&lt;/span&gt;, &lt;span class="pl-s1"&gt;assistant&lt;/span&gt;

&lt;span class="pl-s1"&gt;model&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_model&lt;/span&gt;(&lt;span class="pl-s"&gt;"gpt-5.5"&lt;/span&gt;)

&lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(&lt;span class="pl-s1"&gt;messages&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;[
    &lt;span class="pl-en"&gt;user&lt;/span&gt;(&lt;span class="pl-s"&gt;"Capital of France?"&lt;/span&gt;),
    &lt;span class="pl-en"&gt;assistant&lt;/span&gt;(&lt;span class="pl-s"&gt;"Paris"&lt;/span&gt;),
    &lt;span class="pl-en"&gt;user&lt;/span&gt;(&lt;span class="pl-s"&gt;"Germany?"&lt;/span&gt;),
])
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;text&lt;/span&gt;())&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;llm.user()&lt;/code&gt; and &lt;code&gt;llm.assistant()&lt;/code&gt; functions are new builder functions designed to be used within that &lt;code&gt;messages=[]&lt;/code&gt; array.&lt;/p&gt;
&lt;p&gt;The previous &lt;code&gt;prompt=&lt;/code&gt; option still works, but LLM upgrades it to a single-item messages array behind the scenes.&lt;/p&gt;
&lt;p&gt;You can also now &lt;em&gt;reply&lt;/em&gt; to a response, as an alternative to building a conversation:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;response2&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;reply&lt;/span&gt;(&lt;span class="pl-s"&gt;"How about Hungary?"&lt;/span&gt;)
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;response2&lt;/span&gt;) &lt;span class="pl-c"&gt;# Default __str__() calls .text()&lt;/span&gt;&lt;/pre&gt;
&lt;h4 id="streaming-parts"&gt;Streaming parts&lt;/h4&gt;
&lt;p&gt;The other major new interface in the alpha concerns streaming results back from a prompt.&lt;/p&gt;
&lt;p&gt;Previously, LLM supported streaming like this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(&lt;span class="pl-s"&gt;"Generate an SVG of a pelican riding a bicycle"&lt;/span&gt;)
&lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;chunk&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;:
    &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;chunk&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;""&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;Or this async variant:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;asyncio&lt;/span&gt;
&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;

&lt;span class="pl-s1"&gt;model&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_async_model&lt;/span&gt;(&lt;span class="pl-s"&gt;"gpt-5.5"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(&lt;span class="pl-s"&gt;"Generate an SVG of a pelican riding a bicycle"&lt;/span&gt;)

&lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;run&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;chunk&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;:
        &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;chunk&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;""&lt;/span&gt;, &lt;span class="pl-s1"&gt;flush&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)

&lt;span class="pl-s1"&gt;asyncio&lt;/span&gt;.&lt;span class="pl-c1"&gt;run&lt;/span&gt;(&lt;span class="pl-en"&gt;run&lt;/span&gt;())&lt;/pre&gt;
&lt;p&gt;Many of today's models return mixed types of content. A prompt run against Claude might return reasoning output, then text, then a JSON request for a tool call, then more text content.&lt;/p&gt;
&lt;p&gt;Some models can even execute tools on the server-side, for example OpenAI's &lt;a href="https://developers.openai.com/api/docs/guides/tools-code-interpreter?lang=curl"&gt;code interpreter tool&lt;/a&gt; or Anthropic's &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool"&gt;web search&lt;/a&gt;. This means the results from the model can combine text, tool calls, tool outputs and other formats.&lt;/p&gt;
&lt;p&gt;Multi-modal output models are starting to emerge too, which can return images or even &lt;a href="https://developers.openai.com/api/docs/guides/audio#add-audio-to-your-existing-application"&gt;snippets of audio&lt;/a&gt; intermixed into that streaming response.&lt;/p&gt;
&lt;p&gt;The new LLM alpha models these as a stream of typed message parts. Here's what that looks like as a Python API consumer:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;asyncio&lt;/span&gt;
&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;

&lt;span class="pl-s1"&gt;model&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_model&lt;/span&gt;(&lt;span class="pl-s"&gt;"gpt-5.5"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;prompt&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;"invent 3 cool dogs, first talk about your motivations"&lt;/span&gt;

&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;describe_dog&lt;/span&gt;(&lt;span class="pl-s1"&gt;name&lt;/span&gt;: &lt;span class="pl-smi"&gt;str&lt;/span&gt;, &lt;span class="pl-s1"&gt;bio&lt;/span&gt;: &lt;span class="pl-smi"&gt;str&lt;/span&gt;) &lt;span class="pl-c1"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="pl-smi"&gt;str&lt;/span&gt;:
    &lt;span class="pl-s"&gt;"""Record the name and biography of a hypothetical dog."""&lt;/span&gt;
    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s"&gt;f"&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s1"&gt;name&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s1"&gt;bio&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;"&lt;/span&gt;

&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;sync_example&lt;/span&gt;():
    &lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(
        &lt;span class="pl-s1"&gt;prompt&lt;/span&gt;,
        &lt;span class="pl-s1"&gt;tools&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;[&lt;span class="pl-s1"&gt;describe_dog&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;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;stream_events&lt;/span&gt;():
        &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"text"&lt;/span&gt;:
            &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;chunk&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;""&lt;/span&gt;, &lt;span class="pl-s1"&gt;flush&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
        &lt;span class="pl-k"&gt;elif&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"tool_call_name"&lt;/span&gt;:
            &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s"&gt;f"&lt;span class="pl-cce"&gt;\n&lt;/span&gt;Tool call: &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;chunk&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;("&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;""&lt;/span&gt;, &lt;span class="pl-s1"&gt;flush&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
        &lt;span class="pl-k"&gt;elif&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"tool_call_args"&lt;/span&gt;:
            &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;chunk&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;""&lt;/span&gt;, &lt;span class="pl-s1"&gt;flush&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)

&lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;async_example&lt;/span&gt;():
    &lt;span class="pl-s1"&gt;model&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_async_model&lt;/span&gt;(&lt;span class="pl-s"&gt;"gpt-5.5"&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(
        &lt;span class="pl-s1"&gt;prompt&lt;/span&gt;,
        &lt;span class="pl-s1"&gt;tools&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;[&lt;span class="pl-s1"&gt;describe_dog&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;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;astream_events&lt;/span&gt;():
        &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"text"&lt;/span&gt;:
            &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;chunk&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;""&lt;/span&gt;, &lt;span class="pl-s1"&gt;flush&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
        &lt;span class="pl-k"&gt;elif&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"tool_call_name"&lt;/span&gt;:
            &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s"&gt;f"&lt;span class="pl-cce"&gt;\n&lt;/span&gt;Tool call: &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;chunk&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;("&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;""&lt;/span&gt;, &lt;span class="pl-s1"&gt;flush&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
        &lt;span class="pl-k"&gt;elif&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"tool_call_args"&lt;/span&gt;:
            &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;chunk&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;""&lt;/span&gt;, &lt;span class="pl-s1"&gt;flush&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)

&lt;span class="pl-en"&gt;sync_example&lt;/span&gt;()
&lt;span class="pl-s1"&gt;asyncio&lt;/span&gt;.&lt;span class="pl-c1"&gt;run&lt;/span&gt;(&lt;span class="pl-en"&gt;async_example&lt;/span&gt;())&lt;/pre&gt;
&lt;p&gt;Sample output (from just the first sync example):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;My motivation: create three memorable dogs with distinct “cool” styles—one cinematic, one adventurous, and one charmingly chaotic—so each feels like they could star in their own story.&lt;/code&gt;&lt;br /&gt;
&lt;code&gt;Tool call: describe_dog({"name": "Nova Jetpaw", "bio": "A sleek silver-gray whippet who wears tiny aviator goggles and loves sprinting along moonlit beaches. Nova is fearless, elegant, and rumored to outrun drones just for fun."}&lt;/code&gt;&lt;br /&gt;
&lt;code&gt;Tool call: describe_dog({"name": "Mochi Thunderbark", "bio": "A fluffy corgi with a dramatic black-and-gold bandana and the confidence of a rock star. Mochi is short, loud, loyal, and leads a neighborhood 'security patrol' made entirely of squirrels."}&lt;/code&gt;&lt;br /&gt;
&lt;code&gt;Tool call: describe_dog({"name": "Atlas Snowfang", "bio": "A massive white husky with ice-blue eyes and a backpack full of trail snacks. Atlas is calm, heroic, and always knows the way home—even during blizzards, fog, or confusing camping trips."}&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;At the end of the response you can call &lt;code&gt;response.execute_tool_calls()&lt;/code&gt; to actually run the functions that were requested, or send a &lt;code&gt;response.reply()&lt;/code&gt; to have those tools called and their return values sent back to the model:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;reply&lt;/span&gt;(&lt;span class="pl-s"&gt;"Tell me about the dogs"&lt;/span&gt;))&lt;/pre&gt;
&lt;p&gt;This new mechanism for streaming different token types means the CLI tool can now display "thinking" text in a different color from the text in the final response. The thinking text goes to stderr so it won't affect results that are piped into other tools.&lt;/p&gt;
&lt;p&gt;This example uses Claude Sonnet 4.6 (with an updated streaming event version of the &lt;a href="https://github.com/simonw/llm-anthropic"&gt;llm-anthropic&lt;/a&gt; plugin) as Anthropic's models return their reasoning text as part of the response:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm -m claude-sonnet-4.6 &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Think about 3 cool dogs then describe them&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; \
  -o thinking_display 1&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/claude-thinking-llm.gif" alt="Animated demo. Starts with ~/dev/scratch/llm-anthropic % uv run llm -m claude-sonnet-4.6 'Think about 3 cool dogs then describe them' -o thinking_display 1 - the text then streams in grey: The user wants me to think about 3 cool dogs and then describe them. Let me come up with 3 interesting, cool dogs and describe them. Then switches to regular color text for the output that describes the dogs." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;You can suppress the output of reasoning tokens using the new &lt;code&gt;-R/--no-reasoning&lt;/code&gt; flag. Surprisingly that ended up being the only CLI-facing change in this release.&lt;/p&gt;
&lt;h4 id="a-mechanism-for-serializing-and-deserializing-responses"&gt;A mechanism for serializing and deserializing responses&lt;/h4&gt;
&lt;p&gt;As mentioned earlier, LLM has quite inflexible code at the moment for persisting conversations to SQLite. I've added a new mechanism in 0.32a0 that should provide Python API users a way to roll their own alternative:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;serializable&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;to_dict&lt;/span&gt;()
&lt;span class="pl-c"&gt;# serializable is a JSON-style dictionary&lt;/span&gt;
&lt;span class="pl-c"&gt;# store it anywhere you like, then inflate it:&lt;/span&gt;
&lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Response&lt;/span&gt;.&lt;span class="pl-c1"&gt;from_dict&lt;/span&gt;(&lt;span class="pl-s1"&gt;serializable&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;The dictionary this returns is actually a &lt;code&gt;TypedDict&lt;/code&gt; defined in the new &lt;a href="https://github.com/simonw/llm/blob/main/llm/serialization.py"&gt;llm/serialization.py&lt;/a&gt; module.&lt;/p&gt;
&lt;h4 id="what-s-next-"&gt;What's next?&lt;/h4&gt;
&lt;p&gt;I'm releasing this as an alpha so I can upgrade various plugins and exercise the new design in real world environments for a few days. I expect the stable 0.32 release will be very similar to this alpha, unless alpha testing reveals some design flaw in the way I've put this all together.&lt;/p&gt;
&lt;p&gt;There's one remaining large task: I'd like to redesign the SQLite logging system to better capture the more finely grained details that are returned by this new abstraction.&lt;/p&gt;
&lt;p&gt;Ideally I'd like to model this as a graph, to best support situations like an OpenAI-style chat completions API where the same conversations are constantly extended and then repeated with every prompt. I want to be able to store those without duplicating them in the database.&lt;/p&gt;
&lt;p&gt;I'm undecided as to whether that should be a feature in 0.32 or I should hold it for 0.33.&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/ai"&gt;ai&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/llm"&gt;llm&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="python"/><category term="ai"/><category term="annotated-release-notes"/><category term="generative-ai"/><category term="llms"/><category term="llm"/></entry><entry><title>datasette 1.0a27</title><link href="https://simonwillison.net/2026/Apr/15/datasette/" rel="alternate"/><published>2026-04-15T23:16:34+00:00</published><updated>2026-04-15T23:16:34+00:00</updated><id>https://simonwillison.net/2026/Apr/15/datasette/</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.0a27"&gt;datasette 1.0a27&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;Two major changes in this new Datasette alpha. I covered the first of those &lt;a href="https://simonwillison.net/2026/Apr/14/replace-token-based-csrf/"&gt;in detail yesterday&lt;/a&gt; - Datasette no longer uses Django-style CSRF form tokens, instead using modern browser headers &lt;a href="https://words.filippo.io/csrf"&gt;as described by Filippo Valsorda&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The second big change is that Datasette now fires a new &lt;a href="https://docs.datasette.io/en/latest/events.html#datasette.events.RenameTableEvent"&gt;RenameTableEvent&lt;/a&gt; any time a table is renamed during a SQLite transaction. This is useful because some plugins (like &lt;a href="https://github.com/datasette/datasette-comments"&gt;datasette-comments&lt;/a&gt;) attach additional data to table records by name, so a renamed table requires them to react in appropriate ways.&lt;/p&gt;
&lt;p&gt;Here are the rest of the changes in the alpha:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;a href="https://docs.datasette.io/en/latest/internals.html#internals-datasette-client-actor"&gt;actor= parameter&lt;/a&gt; for &lt;code&gt;datasette.client&lt;/code&gt; methods, allowing internal requests to be made as a specific actor. This is particularly useful for writing automated tests. (&lt;a href="https://github.com/simonw/datasette/pull/2688"&gt;#2688&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;Database(is_temp_disk=True)&lt;/code&gt; option, used internally for the internal database. This helps resolve intermittent database locked errors caused by the internal database being in-memory as opposed to on-disk. (&lt;a href="https://github.com/simonw/datasette/issues/2683"&gt;#2683&lt;/a&gt;) (&lt;a href="https://github.com/simonw/datasette/pull/2684"&gt;#2684&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;/&amp;lt;database&amp;gt;/&amp;lt;table&amp;gt;/-/upsert&lt;/code&gt; API (&lt;a href="https://docs.datasette.io/en/latest/json_api.html#tableupsertview"&gt;docs&lt;/a&gt;) now rejects rows with &lt;code&gt;null&lt;/code&gt; primary key values. (&lt;a href="https://github.com/simonw/datasette/issues/1936"&gt;#1936&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Improved example in the API explorer for the &lt;code&gt;/-/upsert&lt;/code&gt; endpoint (&lt;a href="https://docs.datasette.io/en/latest/json_api.html#tableupsertview"&gt;docs&lt;/a&gt;). (&lt;a href="https://github.com/simonw/datasette/issues/1936"&gt;#1936&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;/&amp;lt;database&amp;gt;.json&lt;/code&gt; endpoint now includes an &lt;code&gt;"ok": true&lt;/code&gt; key, for consistency with other JSON API responses.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.datasette.io/en/latest/internals.html#internals-utils-call-with-supported-arguments"&gt;call_with_supported_arguments()&lt;/a&gt; is now documented as a supported public API. (&lt;a href="https://github.com/simonw/datasette/pull/2678"&gt;#2678&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&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/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="python"/><category term="datasette"/><category term="annotated-release-notes"/></entry><entry><title>datasette-llm 0.1a1</title><link href="https://simonwillison.net/2026/Mar/25/datasette-llm/" rel="alternate"/><published>2026-03-25T21:24:31+00:00</published><updated>2026-03-25T21:24:31+00:00</updated><id>https://simonwillison.net/2026/Mar/25/datasette-llm/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/datasette/datasette-llm/releases/tag/0.1a1"&gt;datasette-llm 0.1a1&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;New release of the base plugin that makes models from &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; available for use by other Datasette plugins such as &lt;a href="https://github.com/datasette/datasette-enrichments-llm"&gt;datasette-enrichments-llm&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;a href="https://github.com/datasette/datasette-llm/blob/main/README.md#register_llm_purposes"&gt;&lt;code&gt;register_llm_purposes()&lt;/code&gt; plugin hook&lt;/a&gt; and &lt;code&gt;get_purposes()&lt;/code&gt; function for retrieving registered purpose strings. &lt;a href="https://github.com/datasette/datasette-llm/issues/1"&gt;#1&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;One of the responsibilities of this plugin is to configure which models are used for which purposes, so you can say in one place "data enrichment uses GPT-5.4-nano but SQL query assistance happens using Sonnet 4.6", for example.&lt;/p&gt;
&lt;p&gt;Plugins that depend on this can use &lt;code&gt;model = await llm.model(purpose="enrichment")&lt;/code&gt; to indicate the purpose of the prompts they wish to execute against the model. Those plugins can now also use the new &lt;code&gt;register_llm_purposes()&lt;/code&gt; hook to register those purpose strings, which means future plugins can list those purposes in one place to power things like an admin UI for assigning models to purposes.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/plugins"&gt;plugins&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;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="plugins"/><category term="datasette"/><category term="annotated-release-notes"/><category term="llm"/></entry><entry><title>datasette-files 0.1a2</title><link href="https://simonwillison.net/2026/Mar/23/datasette-files/" rel="alternate"/><published>2026-03-23T23:06:38+00:00</published><updated>2026-03-23T23:06:38+00:00</updated><id>https://simonwillison.net/2026/Mar/23/datasette-files/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/datasette/datasette-files/releases/tag/0.1a2"&gt;datasette-files 0.1a2&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;The most interesting alpha of &lt;a href="https://github.com/datasette/datasette-files"&gt;datasette-files&lt;/a&gt; yet, a new plugin which adds the ability to upload files directly into a Datasette instance. Here are the release notes in full:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Columns are now configured using the &lt;a href="https://docs.datasette.io/en/latest/changelog.html#new-column-types-system"&gt;new column_types system&lt;/a&gt; from Datasette 1.0a26. &lt;a href="https://github.com/datasette/datasette-files/issues/8"&gt;#8&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;file_actions&lt;/code&gt; plugin hook, plus ability to import an uploaded CSV/TSV file to a table. &lt;a href="https://github.com/datasette/datasette-files/issues/10"&gt;#10&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;UI for uploading multiple files at once via the new documented JSON upload API. &lt;a href="https://github.com/datasette/datasette-files/issues/11"&gt;#11&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Thumbnails are now generated for image files and stored in an internal &lt;code&gt;datasette_files_thumbnails&lt;/code&gt; table. &lt;a href="https://github.com/datasette/datasette-files/issues/13"&gt;#13&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
    
    
        &lt;p&gt;Tags: &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="datasette"/><category term="annotated-release-notes"/></entry><entry><title>Rodney v0.4.0</title><link href="https://simonwillison.net/2026/Feb/17/rodney/" rel="alternate"/><published>2026-02-17T23:02:33+00:00</published><updated>2026-02-17T23:02:33+00:00</updated><id>https://simonwillison.net/2026/Feb/17/rodney/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/rodney/releases/tag/v0.4.0"&gt;Rodney v0.4.0&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
My &lt;a href="https://github.com/simonw/rodney"&gt;Rodney&lt;/a&gt; CLI tool for browser automation attracted quite the flurry of PRs since I announced it &lt;a href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/#rodney-cli-browser-automation-designed-to-work-with-showboat"&gt;last week&lt;/a&gt;. Here are the release notes for the just-released v0.4.0:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Errors now use exit code 2, which means exit code 1 is just for for check failures. &lt;a href="https://github.com/simonw/rodney/pull/15"&gt;#15&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;rodney assert&lt;/code&gt; command for running JavaScript tests, exit code 1 if they fail. &lt;a href="https://github.com/simonw/rodney/issues/19"&gt;#19&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New directory-scoped sessions with &lt;code&gt;--local&lt;/code&gt;/&lt;code&gt;--global&lt;/code&gt; flags. &lt;a href="https://github.com/simonw/rodney/pull/14"&gt;#14&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;reload --hard&lt;/code&gt; and &lt;code&gt;clear-cache&lt;/code&gt; commands. &lt;a href="https://github.com/simonw/rodney/pull/17"&gt;#17&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;rodney start --show&lt;/code&gt; option to make the browser window visible. Thanks, &lt;a href="https://github.com/antocuni"&gt;Antonio Cuni&lt;/a&gt;. &lt;a href="https://github.com/simonw/rodney/paull/13"&gt;#13&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;rodney connect PORT&lt;/code&gt; command to debug an already-running Chrome instance. Thanks, &lt;a href="https://github.com/pnf"&gt;Peter Fraenkel&lt;/a&gt;. &lt;a href="https://github.com/simonw/rodney/pull/12"&gt;#12&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;RODNEY_HOME&lt;/code&gt; environment variable to support custom state directories. Thanks, &lt;a href="https://github.com/senko"&gt;Senko Rašić&lt;/a&gt;. &lt;a href="https://github.com/simonw/rodney/pull/11"&gt;#11&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;--insecure&lt;/code&gt; flag to ignore certificate errors. Thanks, &lt;a href="https://github.com/zgolus"&gt;Jakub Zgoliński&lt;/a&gt;. &lt;a href="https://github.com/simonw/rodney/pull/10"&gt;#10&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Windows support: avoid &lt;code&gt;Setsid&lt;/code&gt; on Windows via build-tag helpers. Thanks, &lt;a href="https://github.com/adm1neca"&gt;adm1neca&lt;/a&gt;. &lt;a href="https://github.com/simonw/rodney/pull/18"&gt;#18&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Tests now run on &lt;code&gt;windows-latest&lt;/code&gt; and &lt;code&gt;macos-latest&lt;/code&gt; in addition to Linux.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I've been using &lt;a href="https://github.com/simonw/showboat"&gt;Showboat&lt;/a&gt; to create demos of new features - here those are for &lt;a href="https://github.com/simonw/rodney/tree/v0.4.0/notes/assert-command-demo"&gt;rodney assert&lt;/a&gt;, &lt;a href="https://github.com/simonw/rodney/tree/v0.4.0/notes/clear-cache-demo"&gt;rodney reload --hard&lt;/a&gt;, &lt;a href="https://github.com/simonw/rodney/tree/v0.4.0/notes/error-codes-demo"&gt;rodney exit codes&lt;/a&gt;, and &lt;a href="https://github.com/simonw/rodney/tree/v0.4.0/notes/local-sessions-demo"&gt;rodney start --local&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;rodney assert&lt;/code&gt; command is pretty neat: you can now Rodney to test a web app through multiple steps in a shell script that looks something like this (adapted from &lt;a href="https://github.com/simonw/rodney/blob/v0.4.0/README.md#combining-checks-in-a-shell-script"&gt;the README&lt;/a&gt;):&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#!&lt;/span&gt;/bin/bash&lt;/span&gt;
&lt;span class="pl-c1"&gt;set&lt;/span&gt; -euo pipefail

FAIL=0

&lt;span class="pl-en"&gt;check&lt;/span&gt;() {
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-k"&gt;!&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$@&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;;&lt;/span&gt; &lt;span class="pl-k"&gt;then&lt;/span&gt;
        &lt;span class="pl-c1"&gt;echo&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;FAIL: &lt;span class="pl-smi"&gt;$*&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
        FAIL=1
    &lt;span class="pl-k"&gt;fi&lt;/span&gt;
}

rodney start
rodney open &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://example.com&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
rodney waitstable

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Assert elements exist&lt;/span&gt;
check rodney exists &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;h1&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Assert key elements are visible&lt;/span&gt;
check rodney visible &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;h1&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
check rodney visible &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;#main-content&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Assert JS expressions&lt;/span&gt;
check rodney assert &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;document.title&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;Example Domain&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
check rodney assert &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;document.querySelectorAll("p").length&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;2&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Assert accessibility requirements&lt;/span&gt;
check rodney ax-find --role navigation

rodney stop

&lt;span class="pl-k"&gt;if&lt;/span&gt; [ &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$FAIL&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;-ne&lt;/span&gt; 0 ]&lt;span class="pl-k"&gt;;&lt;/span&gt; &lt;span class="pl-k"&gt;then&lt;/span&gt;
    &lt;span class="pl-c1"&gt;echo&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Some checks failed&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-c1"&gt;exit&lt;/span&gt; 1
&lt;span class="pl-k"&gt;fi&lt;/span&gt;
&lt;span class="pl-c1"&gt;echo&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;All checks passed&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/browsers"&gt;browsers&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/testing"&gt;testing&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/rodney"&gt;rodney&lt;/a&gt;&lt;/p&gt;



</summary><category term="browsers"/><category term="projects"/><category term="testing"/><category term="annotated-release-notes"/><category term="rodney"/></entry><entry><title>Datasette 1.0a24</title><link href="https://simonwillison.net/2026/Jan/29/datasette-10a24/" rel="alternate"/><published>2026-01-29T17:21:51+00:00</published><updated>2026-01-29T17:21:51+00:00</updated><id>https://simonwillison.net/2026/Jan/29/datasette-10a24/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.datasette.io/en/latest/changelog.html#a24-2026-01-29"&gt;Datasette 1.0a24&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New Datasette alpha this morning. Key new features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Datasette's &lt;code&gt;Request&lt;/code&gt; object can now handle &lt;code&gt;multipart/form-data&lt;/code&gt; file uploads via the new &lt;a href="https://docs.datasette.io/en/latest/internals.html#internals-formdata"&gt;await request.form(files=True)&lt;/a&gt;  method. I plan to use this for a &lt;code&gt;datasette-files&lt;/code&gt; plugin to support attaching files to rows of data.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://docs.datasette.io/en/latest/contributing.html#setting-up-a-development-environment"&gt;recommended development environment&lt;/a&gt; for hacking on Datasette itself now uses &lt;a href="https://github.com/astral-sh/uv"&gt;uv&lt;/a&gt;. Crucially, you can clone Datasette and run &lt;code&gt;uv run pytest&lt;/code&gt; to run the tests without needing to manually create a virtual environment or install dependencies first, thanks to the &lt;a href="https://til.simonwillison.net/uv/dependency-groups"&gt;dev dependency group pattern&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A new &lt;code&gt;?_extra=render_cell&lt;/code&gt; parameter for both table and row JSON pages to return the results of executing the &lt;a href="https://docs.datasette.io/en/latest/plugin_hooks.html#render-cell-row-value-column-table-database-datasette-request"&gt;render_cell() plugin hook&lt;/a&gt;. This should unlock new JavaScript UI features in the future.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;More details &lt;a href="https://docs.datasette.io/en/latest/changelog.html#a24-2026-01-29"&gt;in the release notes&lt;/a&gt;. I also invested a bunch of work in eliminating flaky tests that were intermittently failing in CI - I &lt;em&gt;think&lt;/em&gt; those are all handled now.


    &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/datasette"&gt;datasette&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/uv"&gt;uv&lt;/a&gt;&lt;/p&gt;



</summary><category term="projects"/><category term="python"/><category term="datasette"/><category term="annotated-release-notes"/><category term="uv"/></entry><entry><title>shot-scraper 1.9</title><link href="https://simonwillison.net/2025/Dec/29/shot-scraper/" rel="alternate"/><published>2025-12-29T22:33:13+00:00</published><updated>2025-12-29T22:33:13+00:00</updated><id>https://simonwillison.net/2025/Dec/29/shot-scraper/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/shot-scraper/releases/tag/1.9"&gt;shot-scraper 1.9&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New release of my &lt;a href="https://shot-scraper.datasette.io/"&gt;shot-scraper&lt;/a&gt; CLI tool for taking screenshots and scraping websites with JavaScript from the terminal.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;shot-scraper har&lt;/code&gt; command has a new &lt;code&gt;-x/--extract&lt;/code&gt; option which extracts all of the resources loaded by the page out to a set of files. This location can be controlled by the &lt;code&gt;-o dir/&lt;/code&gt; option. &lt;a href="https://github.com/simonw/shot-scraper/issues/184"&gt;#184&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Fixed the &lt;code&gt;shot-scraper accessibility&lt;/code&gt; command for compatibility with the latest Playwright. &lt;a href="https://github.com/simonw/shot-scraper/issues/185"&gt;#185&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The new &lt;code&gt;shot-scraper har -x https://simonwillison.net/&lt;/code&gt; command is really neat. The inspiration was &lt;a href="https://simonwillison.net/2025/Dec/26/slop-acts-of-kindness/#digital-forensics-with-shot-scraper-har"&gt;the digital forensics expedition&lt;/a&gt; I went on to figure out why Rob Pike got spammed. You can now perform a version of that investigation like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /tmp
shot-scraper har --wait 10000 'https://theaidigest.org/village?day=265' -x
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then dig around in the resulting JSON files in the &lt;code&gt;/tmp/theaidigest-org-village&lt;/code&gt; folder.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&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/shot-scraper"&gt;shot-scraper&lt;/a&gt;&lt;/p&gt;



</summary><category term="projects"/><category term="annotated-release-notes"/><category term="shot-scraper"/></entry><entry><title>s3-credentials 0.17</title><link href="https://simonwillison.net/2025/Dec/16/s3-credentials/" rel="alternate"/><published>2025-12-16T23:40:31+00:00</published><updated>2025-12-16T23:40:31+00:00</updated><id>https://simonwillison.net/2025/Dec/16/s3-credentials/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/s3-credentials/releases/tag/0.17"&gt;s3-credentials 0.17&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New release of my &lt;a href="https://s3-credentials.readthedocs.io/"&gt;s3-credentials&lt;/a&gt; CLI tool for managing credentials needed to access just one S3 bucket. Here are the release notes in full:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New commands &lt;code&gt;get-bucket-policy&lt;/code&gt; and &lt;code&gt;set-bucket-policy&lt;/code&gt;. &lt;a href="https://github.com/simonw/s3-credentials/issues/91"&gt;#91&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New commands &lt;code&gt;get-public-access-block&lt;/code&gt; and &lt;code&gt;set-public-access-block&lt;/code&gt;. &lt;a href="https://github.com/simonw/s3-credentials/issues/92"&gt;#92&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;localserver&lt;/code&gt; command for starting a web server that makes time limited credentials accessible via a JSON API. &lt;a href="https://github.com/simonw/s3-credentials/pull/93"&gt;#93&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;That &lt;code&gt;s3-credentials localserver&lt;/code&gt; command (&lt;a href="https://s3-credentials.readthedocs.io/en/stable/localserver.html"&gt;documented here&lt;/a&gt;) is a little obscure, but I found myself wanting something like that to help me test out a new feature I'm building to help create temporary Litestream credentials using Amazon STS.&lt;/p&gt;
&lt;p&gt;Most of that new feature was &lt;a href="https://gistpreview.github.io/?500add71f397874ebadb8e04e8a33b53"&gt;built by Claude Code&lt;/a&gt; from the following starting prompt:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Add a feature s3-credentials localserver which starts a localhost weberver running (using the Python standard library stuff) on port 8094 by default but -p/--port can set a different port and otherwise takes an option that names a bucket and then takes the same options for read--write/read-only etc as other commands. It also takes a required --refresh-interval option which can be set as 5m or 10h or 30s. All this thing does is reply on / to a GET request with the IAM expiring credentials that allow access to that bucket with that policy for that specified amount of time. It caches internally the credentials it generates and will return the exact same data up until they expire (it also tracks expected expiry time) after which it will generate new credentials (avoiding dog pile effects if multiple requests ask at the same time) and return and cache those instead.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/aws"&gt;aws&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/s3"&gt;s3&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&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/s3-credentials"&gt;s3-credentials&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/prompt-engineering"&gt;prompt-engineering&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/claude-code"&gt;claude-code&lt;/a&gt;&lt;/p&gt;



</summary><category term="aws"/><category term="projects"/><category term="s3"/><category term="ai"/><category term="annotated-release-notes"/><category term="s3-credentials"/><category term="prompt-engineering"/><category term="generative-ai"/><category term="llms"/><category term="coding-agents"/><category term="claude-code"/></entry><entry><title>LLM 0.28</title><link href="https://simonwillison.net/2025/Dec/12/llm-028/" rel="alternate"/><published>2025-12-12T20:20:14+00:00</published><updated>2025-12-12T20:20:14+00:00</updated><id>https://simonwillison.net/2025/Dec/12/llm-028/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://llm.datasette.io/en/stable/changelog.html#v0-28"&gt;LLM 0.28&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I released a new version of my &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; Python library and CLI tool for interacting with Large Language Models. Highlights from the release notes:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New OpenAI models: &lt;code&gt;gpt-5.1&lt;/code&gt;, &lt;code&gt;gpt-5.1-chat-latest&lt;/code&gt;, &lt;code&gt;gpt-5.2&lt;/code&gt; and &lt;code&gt;gpt-5.2-chat-latest&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/1300"&gt;#1300&lt;/a&gt;, &lt;a href="https://github.com/simonw/llm/issues/1317"&gt;#1317&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;When fetching URLs as fragments using &lt;code&gt;llm -f URL&lt;/code&gt;, the request now includes a custom user-agent header: &lt;code&gt;llm/VERSION (https://llm.datasette.io/)&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/1309"&gt;#1309&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Fixed a bug where fragments were not correctly registered with their source when using &lt;code&gt;llm chat&lt;/code&gt;. Thanks, &lt;a href="https://github.com/grota"&gt;Giuseppe Rota&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/pull/1316"&gt;#1316&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Fixed some file descriptor leak warnings. Thanks, &lt;a href="https://github.com/eedeebee"&gt;Eric Bloch&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/1313"&gt;#1313&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Type annotations for the OpenAI Chat, AsyncChat and Completion &lt;code&gt;execute()&lt;/code&gt; methods. Thanks, &lt;a href="https://github.com/ar-jan"&gt;Arjan Mossel&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/pull/1315"&gt;#1315&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;The project now uses &lt;code&gt;uv&lt;/code&gt; and dependency groups for development. See the updated &lt;a href="https://llm.datasette.io/en/stable/contributing.html"&gt;contributing documentation&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/1318"&gt;#1318&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;That last bullet point about &lt;code&gt;uv&lt;/code&gt; relates to the dependency groups pattern I &lt;a href="https://til.simonwillison.net/uv/dependency-groups"&gt;wrote about in a recent TIL&lt;/a&gt;. I'm currently working through applying it to my other projects - the net result is that running the test suite is as simple as doing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/simonw/llm
cd llm
uv run pytest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The new &lt;code&gt;dev&lt;/code&gt; dependency group &lt;a href="https://github.com/simonw/llm/blob/0.28/pyproject.toml#L44-L69"&gt;defined in pyproject.toml&lt;/a&gt; is automatically installed by &lt;code&gt;uv run&lt;/code&gt; in a new virtual environment which means everything needed to run &lt;code&gt;pytest&lt;/code&gt; is available without needing to add any extra commands.


    &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/ai"&gt;ai&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/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;&lt;/p&gt;



</summary><category term="projects"/><category term="python"/><category term="ai"/><category term="annotated-release-notes"/><category term="generative-ai"/><category term="llms"/><category term="llm"/><category term="uv"/></entry><entry><title>sqlite-utils 3.39</title><link href="https://simonwillison.net/2025/Nov/24/sqlite-utils-339/" rel="alternate"/><published>2025-11-24T18:59:14+00:00</published><updated>2025-11-24T18:59:14+00:00</updated><id>https://simonwillison.net/2025/Nov/24/sqlite-utils-339/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v3-39"&gt;sqlite-utils 3.39&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I got a report of &lt;a href="https://github.com/simonw/sqlite-utils/issues/687"&gt;a bug&lt;/a&gt; in &lt;code&gt;sqlite-utils&lt;/code&gt; concerning plugin installation - if you installed the package using &lt;code&gt;uv tool install&lt;/code&gt; further attempts to install plugins with &lt;code&gt;sqlite-utils install X&lt;/code&gt; would fail, because &lt;code&gt;uv&lt;/code&gt; doesn't bundle &lt;code&gt;pip&lt;/code&gt; by default. I had the same bug with Datasette &lt;a href="https://github.com/simonw/sqlite-utils/issues/687"&gt;a while ago&lt;/a&gt;, turns out I forgot to apply the fix to &lt;code&gt;sqlite-utils&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Since I was pushing a new dot-release I decided to integrate some of the non-breaking changes from the 4.0 alpha &lt;a href="https://simonwillison.net/2025/Nov/24/sqlite-utils-40a1/"&gt;I released last night&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I tried to have Claude Code do the backporting for me:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;create a new branch called 3.x starting with the 3.38 tag, then consult 
&lt;a href="https://github.com/simonw/sqlite-utils/issues/688"&gt;https://github.com/simonw/sqlite-utils/issues/688&lt;/a&gt; and cherry-pick the commits it lists in the second comment, then review each of the links in the first comment and cherry-pick those as well. After each cherry-pick run the command "just test" to confirm the tests pass and fix them if they don't. Look through the commit history on main since the 3.38 tag to help you with this task.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This worked reasonably well - &lt;a href="https://gistpreview.github.io/?83c7a7ea96d6b7763ad5d72d251ce1a6"&gt;here's the terminal transcript&lt;/a&gt;. It successfully argued me out of two of the larger changes which would have added more complexity than I want in a small dot-release like this.&lt;/p&gt;
&lt;p&gt;I still had to do a bunch of manual work to get everything up to scratch, which I carried out in &lt;a href="https://github.com/simonw/sqlite-utils/pull/689"&gt;this PR&lt;/a&gt; - including adding comments there and then telling Claude Code:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Apply changes from the review on this PR &lt;a href="https://github.com/simonw/sqlite-utils/pull/689"&gt;https://github.com/simonw/sqlite-utils/pull/689&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's &lt;a href="https://gistpreview.github.io/?f4c89636cc58fc7bf9820c06f2488b91"&gt;the transcript from that&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The release is now out with the following release notes:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Fixed a bug with &lt;code&gt;sqlite-utils install&lt;/code&gt; when the tool had been installed using &lt;code&gt;uv&lt;/code&gt;. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/687"&gt;#687&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;--functions&lt;/code&gt; argument now optionally accepts a path to a Python file as an alternative to a string full of code, and can be specified multiple times - see &lt;a href="https://sqlite-utils.datasette.io/en/stable/cli.html#cli-query-functions"&gt;Defining custom SQL functions&lt;/a&gt;. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/659"&gt;#659&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite-utils&lt;/code&gt; now requires on Python 3.10 or higher.&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/uv"&gt;uv&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;/p&gt;



</summary><category term="projects"/><category term="sqlite"/><category term="sqlite-utils"/><category term="annotated-release-notes"/><category term="uv"/><category term="coding-agents"/><category term="claude-code"/></entry><entry><title>sqlite-utils 4.0a1 has several (minor) backwards incompatible changes</title><link href="https://simonwillison.net/2025/Nov/24/sqlite-utils-40a1/" rel="alternate"/><published>2025-11-24T14:52:34+00:00</published><updated>2025-11-24T14:52:34+00:00</updated><id>https://simonwillison.net/2025/Nov/24/sqlite-utils-40a1/</id><summary type="html">
    &lt;p&gt;I released a &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#a1-2025-11-23"&gt;new alpha version&lt;/a&gt; of &lt;a href="https://sqlite-utils.datasette.io/"&gt;sqlite-utils&lt;/a&gt; last night - the 128th release of that package since I started building it back in 2018.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sqlite-utils&lt;/code&gt; is two things in one package: a Python library for conveniently creating and manipulating SQLite databases and a CLI tool for working with them in the terminal. Almost every feature provided by the package is available via both of those surfaces.&lt;/p&gt;
&lt;p&gt;This is hopefully the last alpha before a 4.0 stable release. I use semantic versioning for this library, so the 4.0 version number indicates that there are backward incompatible changes that may affect code written against the 3.x line.&lt;/p&gt;
&lt;p&gt;These changes are mostly very minor: I don't want to break any existing code if I can avoid it. I made it all the way to version 3.38 before I had to ship a major release and I'm sad I couldn't push that even further!&lt;/p&gt;
&lt;p&gt;Here are the &lt;a href="https://simonwillison.net/tags/annotated-release-notes/"&gt;annotated release notes&lt;/a&gt; for 4.0a1.&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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This change is for type hint enthusiasts. The Python library used to encourage accessing both SQL tables and SQL views through the &lt;code&gt;db["name_of_table_or_view"]&lt;/code&gt; syntactic sugar - but tables and view have different interfaces since there's no way to handle a &lt;code&gt;.insert(row)&lt;/code&gt; on a SQLite view. If you want clean type hints for your code you can now use the &lt;code&gt;db.table(table_name)&lt;/code&gt; and &lt;code&gt;db.view(view_name)&lt;/code&gt; methods instead.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&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/stable/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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;A new feature, not a breaking change. I realized that supporting a stream of lists or tuples as an option for populating large tables would be a neat optimization over always dealing with dictionaries each of which duplicated the column names.&lt;/p&gt;
&lt;p&gt;I had the idea for this one while walking the dog and built the first prototype by prompting Claude Code for web on my phone. Here's &lt;a href="https://github.com/simonw/research/pull/31"&gt;the prompt I used&lt;/a&gt; and the &lt;a href="https://github.com/simonw/research/blob/main/sqlite-utils-iterator-support/README.md"&gt;prototype report it created&lt;/a&gt;, which included a benchmark estimating how much of a performance boost could be had for different sizes of tables.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I was horrified to discover a while ago that I'd been creating SQLite columns called FLOAT but the correct type to use was REAL! This change fixes that. Previously the fix was to ask for tables to be created in strict mode.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;As part of this I also figured out recipes for using &lt;code&gt;uv&lt;/code&gt; as a development environment for the package, which are now baked into the &lt;a href="https://github.com/simonw/sqlite-utils/blob/4.0a1/Justfile"&gt;Justfile&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This one is best explained &lt;a href="https://github.com/simonw/sqlite-utils/issues/655"&gt;in the issue&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;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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Another change which I would have made earlier but, since it introduces a minor behavior change to an existing feature, I reserved it for the 4.0 release.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Back in 2018 when I started this project I was new to working in-depth with SQLite and incorrectly concluded that the correct way to create tables and columns named after reserved words was like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;create table [my table] (
  [id] integer primary key,
  [key] text
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That turned out to be a non-standard SQL syntax which the SQLite documentation &lt;a href="https://sqlite.org/lang_keywords.html"&gt;describes like this&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A keyword enclosed in square brackets is an identifier. This is not standard SQL. This quoting mechanism is used by MS Access and SQL Server and is included in SQLite for compatibility.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Unfortunately I baked it into the library early on and it's been polluting the world with weirdly escaped table and column names ever since!&lt;/p&gt;
&lt;p&gt;I've finally fixed that, with the help of Claude Code which took on the mind-numbing task of &lt;a href="https://github.com/simonw/sqlite-utils/pull/678/files"&gt;updating hundreds of existing tests&lt;/a&gt; that asserted against the generated schemas.&lt;/p&gt;
&lt;p&gt;The above example table schema now looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;create table "my table" (
  "id" integer primary key,
  "key" text
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This may seem like a pretty small change but I expect it to cause a fair amount of downstream pain purely in terms of updating tests that work against tables created by &lt;code&gt;sqlite-utils&lt;/code&gt;!&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I made this change first &lt;a href="https://github.com/simonw/llm/issues/1016#issuecomment-2877305544"&gt;in LLM&lt;/a&gt; and decided to bring it to &lt;code&gt;sqlite-utils&lt;/code&gt; for consistency between the two tools.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&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;p&gt;One last minor ugliness that I waited for a major version bump to fix.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update&lt;/strong&gt;: Now that the embargo has lifted I can reveal that a substantial amount of the work on this release was performed using a preview version of Anthropic's &lt;a href="https://simonwillison.net/2025/Nov/24/claude-opus/"&gt;new Claude Opus 4.5 model&lt;/a&gt;. Here's the &lt;a href="https://gistpreview.github.io/?f40971b693024fbe984a68b73cc283d2"&gt;Claude Code transcript&lt;/a&gt; for the work to implement the ability to use an iterator over lists instead of dictionaries for bulk insert and upsert operations.&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/ai-assisted-programming"&gt;ai-assisted-programming&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;/p&gt;
    

</summary><category term="projects"/><category term="sqlite"/><category term="sqlite-utils"/><category term="annotated-release-notes"/><category term="ai-assisted-programming"/><category term="coding-agents"/><category term="claude-code"/></entry><entry><title>Datasette 1.0a22</title><link href="https://simonwillison.net/2025/Nov/13/datasette-10a22/" rel="alternate"/><published>2025-11-13T23:04:18+00:00</published><updated>2025-11-13T23:04:18+00:00</updated><id>https://simonwillison.net/2025/Nov/13/datasette-10a22/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.datasette.io/en/latest/changelog.html#a22-2025-11-13"&gt;Datasette 1.0a22&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New Datasette 1.0 alpha, adding some small features we needed to properly integrate the new permissions system with Datasette Cloud:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;datasette serve --default-deny&lt;/code&gt; option for running Datasette configured to &lt;a href="https://docs.datasette.io/en/latest/authentication.html#authentication-default-deny"&gt;deny all permissions by default&lt;/a&gt;. (&lt;a href="https://github.com/simonw/datasette/issues/2592"&gt;#2592&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;datasette.is_client()&lt;/code&gt; method for detecting if code is &lt;a href="https://docs.datasette.io/en/latest/internals.html#internals-datasette-is-client"&gt;executing inside a datasette.client request&lt;/a&gt;. (&lt;a href="https://github.com/simonw/datasette/issues/2594"&gt;#2594&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Plus a developer experience improvement for plugin authors:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;datasette.pm&lt;/code&gt; property can now be used to &lt;a href="https://docs.datasette.io/en/latest/testing_plugins.html#testing-plugins-register-in-test"&gt;register and unregister plugins in tests&lt;/a&gt;. (&lt;a href="https://github.com/simonw/datasette/issues/2595"&gt;#2595&lt;/a&gt;)&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/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-cloud"&gt;datasette-cloud&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="datasette"/><category term="datasette-cloud"/><category term="annotated-release-notes"/></entry><entry><title>Open redirect endpoint in Datasette prior to 0.65.2 and 1.0a21</title><link href="https://simonwillison.net/2025/Nov/5/open-redirect-datasette/" rel="alternate"/><published>2025-11-05T23:11:17+00:00</published><updated>2025-11-05T23:11:17+00:00</updated><id>https://simonwillison.net/2025/Nov/5/open-redirect-datasette/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette/security/advisories/GHSA-w832-gg5g-x44m"&gt;Open redirect endpoint in Datasette prior to 0.65.2 and 1.0a21&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
This GitHub security advisory covers two new releases of Datasette that I shipped today, both addressing &lt;a href="https://github.com/simonw/datasette/issues/2429"&gt;the same open redirect issue&lt;/a&gt; with a fix by &lt;a href="https://github.com/jamesjefferies"&gt;James Jefferies&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.datasette.io/en/stable/changelog.html#v0-65-2"&gt;Datasette 0.65.2&lt;/a&gt;&lt;/strong&gt; fixes the bug and also adds Python 3.14 support and a &lt;code&gt;datasette publish cloudrun&lt;/code&gt; fix.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.datasette.io/en/latest/changelog.html#a21-2025-11-05"&gt;Datasette 1.0a21&lt;/a&gt;&lt;/strong&gt; also has that Cloud Run fix and two other small new features:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;code&gt;datasette --get /path --headers&lt;/code&gt; option for inspecting the headers returned by a path. (&lt;a href="https://github.com/simonw/datasette/issues/2578"&gt;#2578&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;datasette.client.get(..., skip_permission_checks=True)&lt;/code&gt; parameter to bypass permission checks when making requests using the internal client. (&lt;a href="https://github.com/simonw/datasette/issues/2583"&gt;#2583&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I decided to include the Cloud Run deployment fix so anyone with Datasette instances deployed to Cloud Run can update them with the new patched versions.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/security"&gt;security&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cloudrun"&gt;cloudrun&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="security"/><category term="datasette"/><category term="cloudrun"/><category term="annotated-release-notes"/></entry><entry><title>A new SQL-powered permissions system in Datasette 1.0a20</title><link href="https://simonwillison.net/2025/Nov/4/datasette-10a20/" rel="alternate"/><published>2025-11-04T21:34:42+00:00</published><updated>2025-11-04T21:34:42+00:00</updated><id>https://simonwillison.net/2025/Nov/4/datasette-10a20/</id><summary type="html">
    &lt;p&gt;&lt;a href="https://docs.datasette.io/en/latest/changelog.html#a20-2025-11-03"&gt;Datasette 1.0a20 is out&lt;/a&gt; with the biggest breaking API change on the road to 1.0, improving how Datasette's permissions system works by migrating permission logic to SQL running in SQLite. This release involved &lt;a href="https://github.com/simonw/datasette/compare/1.0a19...1.0a20"&gt;163 commits&lt;/a&gt;, with 10,660 additions and 1,825 deletions, most of which was written with the help of Claude Code.&lt;/p&gt;


&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#understanding-the-permissions-system"&gt;Understanding the permissions system&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#permissions-systems-need-to-be-able-to-efficiently-list-things"&gt;Permissions systems need to be able to efficiently list things&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#the-new-permission-resources-sql-plugin-hook"&gt;The new permission_resources_sql() plugin hook&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#hierarchies-plugins-vetoes-and-restrictions"&gt;Hierarchies, plugins, vetoes, and restrictions&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#new-debugging-tools"&gt;New debugging tools&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#the-missing-feature-list-actors-who-can-act-on-this-resource"&gt;The missing feature: list actors who can act on this resource&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#upgrading-plugins-for-datasette-1-0a20"&gt;Upgrading plugins for Datasette 1.0a20&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#using-claude-code-to-implement-this-change"&gt;Using Claude Code to implement this change&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#starting-with-a-proof-of-concept"&gt;Starting with a proof-of-concept&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#miscellaneous-tips-i-picked-up-along-the-way"&gt;Miscellaneous tips I picked up along the way&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#what-s-next-"&gt;What's next?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id="understanding-the-permissions-system"&gt;Understanding the permissions system&lt;/h4&gt;
&lt;p&gt;Datasette's &lt;a href="https://docs.datasette.io/en/latest/authentication.html"&gt;permissions system&lt;/a&gt; exists to answer the following question:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Is this &lt;strong&gt;actor&lt;/strong&gt; allowed to perform this &lt;strong&gt;action&lt;/strong&gt;, optionally against this particular &lt;strong&gt;resource&lt;/strong&gt;?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;An &lt;strong&gt;actor&lt;/strong&gt; is usually a user, but might also be an automation operating via the Datasette API.&lt;/p&gt;
&lt;p&gt;An &lt;strong&gt;action&lt;/strong&gt; is a thing they need to do - things like view-table, execute-sql, insert-row.&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;resource&lt;/strong&gt; is the subject of the action - the database you are executing SQL against, the table you want to insert a row into.&lt;/p&gt;
&lt;p&gt;Datasette's default configuration is public but read-only: anyone can view databases and tables or execute read-only SQL queries but no-one can modify data.&lt;/p&gt;
&lt;p&gt;Datasette plugins can enable all sorts of additional ways to interact with databases, many of which need to be protected by a form of authentication Datasette also 1.0 includes &lt;a href="https://simonwillison.net/2022/Dec/2/datasette-write-api/"&gt;a write API&lt;/a&gt; with a need to configure who can insert, update, and delete rows or create new tables.&lt;/p&gt;
&lt;p&gt;Actors can be authenticated in a number of different ways provided by plugins using the &lt;a href="https://docs.datasette.io/en/latest/plugin_hooks.html#actor-from-request-datasette-request"&gt;actor_from_request()&lt;/a&gt; plugin hook. &lt;a href="https://datasette.io/plugins/datasette-auth-passwords"&gt;datasette-auth-passwords&lt;/a&gt; and &lt;a href="https://datasette.io/plugins/datasette-auth-github"&gt;datasette-auth-github&lt;/a&gt; and &lt;a href="https://datasette.io/plugins/datasette-auth-existing-cookies"&gt;datasette-auth-existing-cookies&lt;/a&gt; are examples of authentication plugins.&lt;/p&gt;
&lt;h4 id="permissions-systems-need-to-be-able-to-efficiently-list-things"&gt;Permissions systems need to be able to efficiently list things&lt;/h4&gt;
&lt;p&gt;The previous implementation included a design flaw common to permissions systems of this nature: each permission check involved a function call which would delegate to one or more plugins and return a True/False result.&lt;/p&gt;
&lt;p&gt;This works well for single checks, but has a significant problem: what if you need to show the user a list of things they can access, for example the tables they can view?&lt;/p&gt;
&lt;p&gt;I want Datasette to be able to handle potentially thousands of tables - tables in SQLite are cheap! I don't want to have to run 1,000+ permission checks just to show the user a list of tables.&lt;/p&gt;
&lt;p&gt;Since Datasette is built on top of SQLite we already have a powerful mechanism to help solve this problem. SQLite is &lt;em&gt;really&lt;/em&gt; good at filtering large numbers of records.&lt;/p&gt;
&lt;h4 id="the-new-permission-resources-sql-plugin-hook"&gt;The new permission_resources_sql() plugin hook&lt;/h4&gt;
&lt;p&gt;The biggest change in the new release is that I've replaced the previous  &lt;code&gt;permission_allowed(actor, action, resource)&lt;/code&gt; plugin hook - which let a plugin determine if an actor could perform an action against a resource - with a new &lt;a href="https://docs.datasette.io/en/latest/plugin_hooks.html#plugin-hook-permission-resources-sql"&gt;permission_resources_sql(actor, action)&lt;/a&gt; plugin hook.&lt;/p&gt;
&lt;p&gt;Instead of returning a True/False result, this new hook returns a SQL query that returns rules helping determine the resources the current actor can execute the specified action against.&lt;/p&gt;
&lt;p&gt;Here's an example, lifted from the documentation:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;datasette&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;hookimpl&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;datasette&lt;/span&gt;.&lt;span class="pl-s1"&gt;permissions&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;PermissionSQL&lt;/span&gt;


&lt;span class="pl-en"&gt;@&lt;span class="pl-s1"&gt;hookimpl&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;permission_resources_sql&lt;/span&gt;(&lt;span class="pl-s1"&gt;datasette&lt;/span&gt;, &lt;span class="pl-s1"&gt;actor&lt;/span&gt;, &lt;span class="pl-s1"&gt;action&lt;/span&gt;):
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;action&lt;/span&gt; &lt;span class="pl-c1"&gt;!=&lt;/span&gt; &lt;span class="pl-s"&gt;"view-table"&lt;/span&gt;:
        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-c1"&gt;None&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;actor&lt;/span&gt; &lt;span class="pl-c1"&gt;or&lt;/span&gt; &lt;span class="pl-s1"&gt;actor&lt;/span&gt;.&lt;span class="pl-c1"&gt;get&lt;/span&gt;(&lt;span class="pl-s"&gt;"id"&lt;/span&gt;) &lt;span class="pl-c1"&gt;!=&lt;/span&gt; &lt;span class="pl-s"&gt;"alice"&lt;/span&gt;:
        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-c1"&gt;None&lt;/span&gt;

    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-en"&gt;PermissionSQL&lt;/span&gt;(
        &lt;span class="pl-s1"&gt;sql&lt;/span&gt;&lt;span class="pl-c1"&gt;=&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;                'accounting' AS parent,&lt;/span&gt;
&lt;span class="pl-s"&gt;                'sales' AS child,&lt;/span&gt;
&lt;span class="pl-s"&gt;                1 AS allow,&lt;/span&gt;
&lt;span class="pl-s"&gt;                'alice can view accounting/sales' AS reason&lt;/span&gt;
&lt;span class="pl-s"&gt;        """&lt;/span&gt;,
    )&lt;/pre&gt;
&lt;p&gt;This hook grants the actor with ID "alice" permission to view the "sales" table in the "accounting" database.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;PermissionSQL&lt;/code&gt; object should always return four columns: a parent, child, allow (1 or 0), and a reason string for debugging.&lt;/p&gt;
&lt;p&gt;When you ask Datasette to list the resources an actor can access for a specific action, it will combine the SQL returned by all installed plugins into a single query that joins against &lt;a href="https://docs.datasette.io/en/latest/internals.html#internal-database-schema"&gt;the internal catalog tables&lt;/a&gt; and efficiently lists all the resources the actor can access.&lt;/p&gt;
&lt;p&gt;This query can then be limited or paginated to avoid loading too many results at once.&lt;/p&gt;
&lt;h4 id="hierarchies-plugins-vetoes-and-restrictions"&gt;Hierarchies, plugins, vetoes, and restrictions&lt;/h4&gt;
&lt;p&gt;Datasette has several additional requirements that make the permissions system more complicated.&lt;/p&gt;
&lt;p&gt;Datasette permissions can optionally act against a two-level &lt;strong&gt;hierarchy&lt;/strong&gt;. You can grant a user the ability to insert-row against a specific table, or every table in a specific database, or every table in &lt;em&gt;every&lt;/em&gt; database in that Datasette instance.&lt;/p&gt;
&lt;p&gt;Some actions can apply at the table level, others the database level and others only make sense globally - enabling a new feature that isn't tied to tables or databases, for example.&lt;/p&gt;
&lt;p&gt;Datasette currently has &lt;a href="https://docs.datasette.io/en/latest/authentication.html#built-in-actions"&gt;ten default actions&lt;/a&gt; but &lt;strong&gt;plugins&lt;/strong&gt; that add additional features can &lt;a href="https://docs.datasette.io/en/latest/plugin_hooks.html#register-actions-datasette"&gt;register new actions&lt;/a&gt; to better participate in the permission systems.&lt;/p&gt;
&lt;p&gt;Datasette's permission system has a mechanism to &lt;strong&gt;veto&lt;/strong&gt; permission checks - a plugin can return a deny for a specific permission check which will override any allows. This needs to be hierarchy-aware - a deny at the database level can be outvoted by an allow at the table level.&lt;/p&gt;
&lt;p&gt;Finally, Datasette includes a mechanism for applying additional &lt;strong&gt;restrictions&lt;/strong&gt; to a request. This was introduced for Datasette's API - it allows a user to create an API token that can act on their behalf but is only allowed to perform a subset of their capabilities - just reading from two specific tables, for example. Restrictions are &lt;a href="https://docs.datasette.io/en/latest/authentication.html#restricting-the-actions-that-a-token-can-perform"&gt;described in more detail&lt;/a&gt; in the documentation.&lt;/p&gt;
&lt;p&gt;That's a lot of different moving parts for the new implementation to cover.&lt;/p&gt;
&lt;h4 id="new-debugging-tools"&gt;New debugging tools&lt;/h4&gt;
&lt;p&gt;Since permissions are critical to the security of a Datasette deployment it's vital that they are as easy to understand and debug as possible.&lt;/p&gt;
&lt;p&gt;The new alpha adds several new debugging tools, including this page that shows the full list of resources matching a specific action for the current user:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/datasette-allowed-resources.jpg" alt="Allowed resources. Tabs are Playground, Check, Allowed, Rules, Actions, Allow debug. There is a form where you can select an action (here view-table) and optionally filter by parent and child. Below is a table of results listing resource paths - e.g. /fixtures/name-of-table - plus parent, child and reason columns. The reason is a JSON list for example &amp;quot;datasette.default_permissions: root user&amp;quot;,&amp;quot;datasette.default_permissions: default allow for view-table&amp;quot;." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;And this page listing the &lt;em&gt;rules&lt;/em&gt; that apply to that question - since different plugins may return different rules which get combined together:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/datasette-rules.jpg" alt="The rules tab for the same view-table question. Here there are two allow rules - one from datasette.default_permissions for the root user and another from default_permissions labelled default allow for view-table." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;This screenshot illustrates two of Datasette's built-in rules: there is a default allow for read-only operations such as view-table (which can be over-ridden by plugins) and another rule that says the root user can do anything (provided Datasette was started with the &lt;code&gt;--root&lt;/code&gt; option.)&lt;/p&gt;
&lt;p&gt;Those rules are defined in the &lt;a href="https://github.com/simonw/datasette/blob/1.0a20/datasette/default_permissions.py"&gt;datasette/default_permissions.py&lt;/a&gt; Python module.&lt;/p&gt;
&lt;h4 id="the-missing-feature-list-actors-who-can-act-on-this-resource"&gt;The missing feature: list actors who can act on this resource&lt;/h4&gt;
&lt;p&gt;There's one question that the new system cannot answer: provide a full list of actors who can perform this action against this resource.&lt;/p&gt;
&lt;p&gt;It's not possibly to provide this globally for Datasette because Datasette doesn't have a way to track what "actors" exist in the system. SSO plugins such as &lt;code&gt;datasette-auth-github&lt;/code&gt; mean a new authenticated GitHub user might show up at any time, with the ability to perform actions despite the Datasette system never having encountered that particular username before.&lt;/p&gt;
&lt;p&gt;API tokens and actor restrictions come into play here as well. A user might create a signed API token that can perform a subset of actions on their behalf - the existence of that token can't be predicted by the permissions system.&lt;/p&gt;
&lt;p&gt;This is a notable omission, but it's also quite common in other systems. AWS cannot provide a list of all actors who have permission to access a specific S3 bucket, for example - presumably for similar reasons.&lt;/p&gt;
&lt;h4 id="upgrading-plugins-for-datasette-1-0a20"&gt;Upgrading plugins for Datasette 1.0a20&lt;/h4&gt;
&lt;p&gt;Datasette's plugin ecosystem is the reason I'm paying so much attention to ensuring Datasette 1.0 has a stable API. I don't want plugin authors to need to chase breaking changes once that 1.0 release is out.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://docs.datasette.io/en/latest/upgrade_guide.html"&gt;Datasette upgrade guide&lt;/a&gt; includes detailed notes on upgrades that are needed between the 0.x and 1.0 alpha releases. I've added an extensive section about the permissions changes to that document.&lt;/p&gt;
&lt;p&gt;I've also been experimenting with dumping those instructions directly into coding agent tools - Claude Code and Codex CLI - to have them upgrade existing plugins for me. This has been working &lt;em&gt;extremely well&lt;/em&gt;. I've even had Claude Code &lt;a href="https://github.com/simonw/datasette/commit/fa978ec1006297416e2cd87a2f0d3cac99283cf8"&gt;update those notes itself&lt;/a&gt; with things it learned during an upgrade process!&lt;/p&gt;
&lt;p&gt;This is greatly helped by the fact that every single Datasette plugin has an automated test suite that demonstrates the core functionality works as expected. Coding agents can use those tests to verify that their changes have had the desired effect.&lt;/p&gt;
&lt;p&gt;I've also been leaning heavily on &lt;code&gt;uv&lt;/code&gt; to help with the upgrade process. I wrote myself two new helper scripts - &lt;code&gt;tadd&lt;/code&gt; and &lt;code&gt;radd&lt;/code&gt; - to help test the new plugins.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tadd&lt;/code&gt; = "test against datasette dev" - it runs a plugin's existing test suite against the current development version of Datasette checked out on my machine. It passes extra options through to &lt;code&gt;pytest&lt;/code&gt; so I can run &lt;code&gt;tadd -k test_name&lt;/code&gt; or &lt;code&gt;tadd -x --pdb&lt;/code&gt; as needed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;radd&lt;/code&gt; = "run against datasette dev" - it runs the latest dev &lt;code&gt;datasette&lt;/code&gt; command with the plugin installed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;code&gt;tadd&lt;/code&gt; and &lt;code&gt;radd&lt;/code&gt; implementations &lt;a href="https://til.simonwillison.net/python/uv-tests#variants-tadd-and-radd"&gt;can be found in this TIL&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Some of my plugin upgrades have become a one-liner to the &lt;code&gt;codex exec&lt;/code&gt; command, which runs OpenAI Codex CLI with a prompt without entering interactive mode:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;codex &lt;span class="pl-c1"&gt;exec&lt;/span&gt; --dangerously-bypass-approvals-and-sandbox \
&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Run the command tadd and look at the errors and then&lt;/span&gt;
&lt;span class="pl-s"&gt;read ~/dev/datasette/docs/upgrade-1.0a20.md and apply&lt;/span&gt;
&lt;span class="pl-s"&gt;fixes and run the tests again and get them to pass&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;There are still a bunch more to go - there's &lt;a href="https://github.com/simonw/datasette/issues/2577"&gt;a list in this tracking issue&lt;/a&gt; - but I expect to have the plugins I maintain all upgraded pretty quickly now that I have a solid process in place.&lt;/p&gt;
&lt;h4 id="using-claude-code-to-implement-this-change"&gt;Using Claude Code to implement this change&lt;/h4&gt;
&lt;p&gt;This change to Datasette core &lt;em&gt;by far&lt;/em&gt; the most ambitious piece of work I've ever attempted using a coding agent.&lt;/p&gt;
&lt;p&gt;Last year I agreed with the prevailing opinion that LLM assistance was much more useful for greenfield coding tasks than working on existing codebases. The amount you could usefully get done was greatly limited by the need to fit the entire codebase into the model's context window.&lt;/p&gt;
&lt;p&gt;Coding agents have entirely changed that calculation. Claude Code and Codex CLI still have relatively limited token windows - albeit larger than last year - but their ability to search through the codebase, read extra files on demand and "reason" about the code they are working with has made them vastly more capable.&lt;/p&gt;
&lt;p&gt;I no longer see codebase size as a limiting factor for how useful they can be.&lt;/p&gt;
&lt;p&gt;I've also spent enough time with Claude Sonnet 4.5 to build a weird level of trust in it. I can usually predict exactly what changes it will make for a prompt. If I tell it "extract this code into a separate function" or "update every instance of this pattern" I know it's likely to get it right.&lt;/p&gt;
&lt;p&gt;For something like permission code I still review everything it does, often by watching it as it works since it displays diffs in the UI.&lt;/p&gt;
&lt;p&gt;I also pay extremely close attention to the tests it's writing. Datasette 1.0a19 already had 1,439 tests, many of which exercised the existing permission system. 1.0a20 increases that to 1,583 tests. I feel very good about that, especially since most of the existing tests continued to pass without modification.&lt;/p&gt;
&lt;h4 id="starting-with-a-proof-of-concept"&gt;Starting with a proof-of-concept&lt;/h4&gt;
&lt;p&gt;I built several different proof-of-concept implementations of SQL permissions before settling on the final design. My &lt;a href="https://github.com/simonw/research/tree/main/sqlite-permissions-poc"&gt;research/sqlite-permissions-poc&lt;/a&gt; project was the one that finally convinced me of a viable approach,&lt;/p&gt;
&lt;p&gt;That one started as a &lt;a href="https://claude.ai/share/8fd432bc-a718-4883-9978-80ab82a75c87"&gt;free ranging conversation with Claude&lt;/a&gt;, at the end of which I told it to generate a specification which I then &lt;a href="https://chatgpt.com/share/68f6532f-9920-8006-928a-364e15b6e9ef"&gt;fed into GPT-5&lt;/a&gt; to implement. You can see that specification &lt;a href="https://github.com/simonw/research/tree/main/sqlite-permissions-poc#original-prompt"&gt;at the end of the README&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I later fed the POC itself into Claude Code and had it implement the first version of the new Datasette system based on that previous experiment.&lt;/p&gt;
&lt;p&gt;This is admittedly a very weird way of working, but it helped me finally break through on a problem that I'd been struggling with for months.&lt;/p&gt;
&lt;h4 id="miscellaneous-tips-i-picked-up-along-the-way"&gt;Miscellaneous tips I picked up along the way&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;When working on anything relating to plugins it's vital to have at least a few real plugins that you upgrade in lock-step with the core changes. The &lt;code&gt;tadd&lt;/code&gt; and &lt;code&gt;radd&lt;/code&gt; shortcuts were invaluable for productively working on those plugins while I made changes to core.&lt;/li&gt;
&lt;li&gt;Coding agents make experiments &lt;em&gt;much&lt;/em&gt; cheaper. I threw away so much code on the way to the final implementation, which was psychologically easier because the cost to create that code in the first place was so low.&lt;/li&gt;
&lt;li&gt;Tests, tests, tests. This project would have been impossible without that existing test suite. The additional tests we built along the way give me confidence that the new system is as robust as I need it to be.&lt;/li&gt;
&lt;li&gt;Claude writes good commit messages now! I finally gave in and let it write these - previously I've been determined to write them myself. It's a big time saver to be able to say "write a tasteful commit message for these changes".&lt;/li&gt;
&lt;li&gt;Claude is also great at breaking up changes into smaller commits. It can also productively rewrite history to make it easier to follow, especially useful if you're still working in a branch.&lt;/li&gt;
&lt;li&gt;A really great way to review Claude's changes is with the GitHub PR interface. You can attach comments to individual lines of code and then later prompt Claude like this: &lt;code&gt;Use gh CLI to fetch comments on URL-to-PR and make the requested changes&lt;/code&gt;. This is a very quick way to apply little nitpick changes - rename this function, refactor this repeated code, add types here etc.&lt;/li&gt;
&lt;li&gt;The code I write with LLMs is &lt;em&gt;higher quality code&lt;/em&gt;. I usually find myself making constant trade-offs while coding: this function would be neater if I extracted this helper, it would be nice to have inline documentation here, this changing this would be good but would break a dozen tests... for each of those I have to determine if the additional time is worth the benefit. Claude can apply changes so much faster than me that these calculations have changed - almost any improvement is worth applying, no matter how trivial, because the time cost is so low.&lt;/li&gt;
&lt;li&gt;Internal tools are cheap now. The new debugging interfaces were mostly written by Claude and are significantly nicer to use and look at than the hacky versions I would have knocked out myself, if I had even taken the extra time to build them.&lt;/li&gt;
&lt;li&gt;That trick with a Markdown file full of upgrade instructions works astonishingly well - it's the same basic idea as &lt;a href="https://simonwillison.net/2025/Oct/16/claude-skills/"&gt;Claude Skills&lt;/a&gt;. I maintain over 100 Datasette plugins now and I expect I'll be automating all sorts of minor upgrades in the future using this technique.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="what-s-next-"&gt;What's next?&lt;/h4&gt;
&lt;p&gt;Now that the new alpha is out my focus is upgrading the existing plugin ecosystem to use it, and supporting other plugin authors who are doing the same.&lt;/p&gt;
&lt;p&gt;The new permissions system unlocks some key improvements to Datasette Cloud concerning finely-grained permissions for larger teams, so I'll be integrating the new alpha there this week.&lt;/p&gt;
&lt;p&gt;This is the single biggest backwards-incompatible change required before Datasette 1.0. I plan to apply the lessons I learned from this project to the other, less intimidating changes. I'm hoping this can result in a final 1.0 release before the end of the year!&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/plugins"&gt;plugins&lt;/a&gt;, &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/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;a href="https://simonwillison.net/tags/uv"&gt;uv&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;/p&gt;
    

</summary><category term="plugins"/><category term="projects"/><category term="python"/><category term="sql"/><category term="sqlite"/><category term="datasette"/><category term="annotated-release-notes"/><category term="uv"/><category term="coding-agents"/><category term="claude-code"/><category term="codex"/></entry><entry><title>LLM 0.27, the annotated release notes: GPT-5 and improved tool calling</title><link href="https://simonwillison.net/2025/Aug/11/llm-027/" rel="alternate"/><published>2025-08-11T23:57:50+00:00</published><updated>2025-08-11T23:57:50+00:00</updated><id>https://simonwillison.net/2025/Aug/11/llm-027/</id><summary type="html">
    &lt;p&gt;I shipped &lt;a href="https://llm.datasette.io/en/stable/changelog.html#v0-27"&gt;LLM 0.27&lt;/a&gt; today (followed by a &lt;a href="https://llm.datasette.io/en/stable/changelog.html#v0-27-1"&gt;0.27.1 with minor bug fixes&lt;/a&gt;), adding support for the new GPT-5 family of models from OpenAI plus a flurry of improvements to the tool calling features &lt;a href="https://simonwillison.net/2025/May/27/llm-tools/"&gt;introduced in LLM 0.26&lt;/a&gt;. Here are the &lt;a href="https://simonwillison.net/tags/annotated-release-notes/"&gt;annotated release notes&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="gpt-5"&gt;GPT-5&lt;/h4&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New models: &lt;code&gt;gpt-5&lt;/code&gt;, &lt;code&gt;gpt-5-mini&lt;/code&gt; and &lt;code&gt;gpt-5-nano&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/1229"&gt;#1229&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I would have liked to get these out sooner, but LLM had accumulated quite a lot of other changes since the last release and I wanted to use GPT-5 as an excuse to wrap all of those up and get them out there.&lt;/p&gt;
&lt;p&gt;These models work much the same as other OpenAI models, but they have a new &lt;code&gt;reasoning_effort&lt;/code&gt; option of &lt;code&gt;minimal&lt;/code&gt;. You can try that out like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -m gpt-5 'A letter advocating for cozy boxes for pelicans in Half Moon Bay harbor' -o reasoning_effort minimal
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Setting "minimal" almost completely eliminates the "thinking" time for the model, causing it to behave more like GPT-4o.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/49838dbca944d3f22dfe65ef11c5637d"&gt;the letter it wrote me&lt;/a&gt; at a cost of 20 input, 706 output = &lt;a href="https://www.llm-prices.com/#it=20&amp;amp;ot=706&amp;amp;ic=1.25&amp;amp;oc=10"&gt;$0.007085 which is 0.7085 cents&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You can set the default model to GPT-5-mini (since it's a bit cheaper) like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm models default gpt-5-mini
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id="tools-in-templates"&gt;Tools in templates&lt;/h4&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;LLM &lt;a href="https://llm.datasette.io/en/stable/templates.html#prompt-templates"&gt;templates&lt;/a&gt; can now include a list of tools. These can be named tools from plugins or arbitrary Python function blocks, see &lt;a href="https://llm.datasette.io/en/stable/templates.html#prompt-templates-tools"&gt;Tools in templates&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/1009"&gt;#1009&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I think this is the most important feature in the new release.&lt;/p&gt;
&lt;p&gt;I added LLM's &lt;a href="https://simonwillison.net/2025/May/27/llm-tools/"&gt;tool calling features&lt;/a&gt; in LLM 0.26. You can call them from the Python API but you can also call them from the command-line like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -T llm_version -T llm_time 'Tell the time, then show the version'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/65d830f8cb38cdeb78093d6ac890ce2c#response-1"&gt;the output&lt;/a&gt; of &lt;code&gt;llm logs -c&lt;/code&gt; after running that command.&lt;/p&gt;
&lt;p&gt;This example shows that you have to explicitly list all of the tools you would like to expose to the model, using the &lt;code&gt;-T/--tool&lt;/code&gt; option one or more times.&lt;/p&gt;
&lt;p&gt;In LLM 0.27 you can now save these tool collections to &lt;a href="https://llm.datasette.io/en/stable/templates.html"&gt;a template&lt;/a&gt;. Let's try that now:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -T llm_version -T llm_time -m gpt-5 --save mytools
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now &lt;code&gt;mytools&lt;/code&gt; is a template that bundles those two tools and sets the default model to GPT-5. We can run it like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -t mytools 'Time then version'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let's do something more fun. My blog has a &lt;a href="https://datasette.simonwillison.net/"&gt;Datasette mirror&lt;/a&gt; which I can run queries against. I'm going to use the &lt;a href="https://github.com/simonw/llm-tools-datasette"&gt;llm-tools-datasette&lt;/a&gt; plugin to turn that into a tool-driven template. This plugin uses a "toolbox", which looks a bit like a class. Those are &lt;a href="https://llm.datasette.io/en/stable/python-api.html#toolbox-classes"&gt;described here&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm install llm-tools-datasette

# Now create that template
llm --tool 'Datasette("https://datasette.simonwillison.net/simonwillisonblog")' \
  -m gpt-5 -s 'Use Datasette tools to answer questions' --save blog
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now I can ask questions of my database like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -t blog 'top ten tags by number of entries'&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--td&lt;/code&gt; option there stands for &lt;code&gt;--tools-debug&lt;/code&gt; - it means we can see all tool calls as they are run.&lt;/p&gt;
&lt;p&gt;Here's the output of the above:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Top 10 tags by number of entries (excluding drafts):
- quora — 1003
- projects — 265
- datasette — 238
- python — 213
- ai — 200
- llms — 200
- generative-ai — 197
- weeknotes — 193
- web-development — 166
- startups — 157
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://gist.github.com/simonw/7b2d0d327afc32ad6c90179fa76290ad"&gt;Full transcript with tool traces here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I'm really excited about the ability to store configured tools&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Tools &lt;a href="https://llm.datasette.io/en/stable/python-api.html#python-api-tools-attachments"&gt;can now return attachments&lt;/a&gt;, for models that support features such as image input. &lt;a href="https://github.com/simonw/llm/issues/1014"&gt;#1014&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I want to build a tool that can render SVG to an image, then return that image so the model can see what it has drawn. For reasons.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New methods on the &lt;code&gt;Toolbox&lt;/code&gt; class: &lt;code&gt;.add_tool()&lt;/code&gt;, &lt;code&gt;.prepare()&lt;/code&gt; and &lt;code&gt;.prepare_async()&lt;/code&gt;, described in &lt;a href="https://llm.datasette.io/en/stable/python-api.html#python-api-tools-dynamic"&gt;Dynamic toolboxes&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/1111"&gt;#1111&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I added these because there's a lot of interest in an MCP plugin for Datasette. Part of the challenge with MCP is that the user provides the URL to a server but we then need to introspect that server and dynamically add the tools we have discovered there. The new &lt;code&gt;.add_tool()&lt;/code&gt; method can do that, and the &lt;code&gt;.prepare()&lt;/code&gt; and &lt;code&gt;.prepare_async()&lt;/code&gt; methods give us a reliable way to run some discovery code outside of the class constructor, allowing it to make asynchronous calls if necessary.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;code&gt;model.conversation(before_call=x, after_call=y)&lt;/code&gt; attributes for registering callback functions to run before and after tool calls. See &lt;a href="https://llm.datasette.io/en/stable/python-api.html#python-api-tools-debug-hooks"&gt;tool debugging hooks&lt;/a&gt; for details. &lt;a href="https://github.com/simonw/llm/issues/1088"&gt;#1088&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Raising &lt;code&gt;llm.CancelToolCall&lt;/code&gt; now only cancels the current tool call, passing an error back to the model and allowing it to continue. &lt;a href="https://github.com/simonw/llm/issues/1148"&gt;#1148&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;These hooks are useful for implementing more complex tool calling at the Python API layer. In addition to debugging and logging they allow Python code to intercept tool calls and cancel or delay them based on what they are trying to do.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Some model providers can serve different models from the same configured URL - &lt;a href="https://github.com/simonw/llm-llama-server"&gt;llm-llama-server&lt;/a&gt; for example. Plugins for these providers can now record the resolved model ID of the model that was used to the LLM logs using the &lt;code&gt;response.set_resolved_model(model_id)&lt;/code&gt; method. &lt;a href="https://github.com/simonw/llm/issues/1117"&gt;#1117&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This solves a frustration I've had for a while where some of my plugins log the same model ID for requests that were processed by a bunch of different models under the hood - making my logs less valuable. The new mechanism now allows plugins to record a more accurate model ID for a prompt, should it differ from the model ID that was requsted.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;code&gt;-l/--latest&lt;/code&gt; option for &lt;code&gt;llm logs -q searchterm&lt;/code&gt; for searching logs ordered by date (most recent first) instead of the default relevance search. &lt;a href="https://github.com/simonw/llm/issues/1177"&gt;#1177&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;My personal &lt;a href="https://llm.datasette.io/en/stable/logging.html"&gt;log database&lt;/a&gt; has grown to over 8,000 entries now, and running full-text search queries against it often returned results from last year that were no longer relevant to me. Being able to find the &lt;em&gt;latest&lt;/em&gt; prompt matching "pelican svg" is much more useful.&lt;/p&gt;
&lt;p&gt;Everything else was bug fixes and documentation improvements:&lt;/p&gt;
&lt;blockquote&gt;
&lt;h3 id="bug-fixes-and-documentation"&gt;Bug fixes and documentation&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;register_embedding_models&lt;/code&gt; hook is &lt;a href="https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-embedding-models-register"&gt;now documented&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/1049"&gt;#1049&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Show visible stack trace for &lt;code&gt;llm templates show invalid-template-name&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/1053"&gt;#1053&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Handle invalid tool names more gracefully in &lt;code&gt;llm chat&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/1104"&gt;#1104&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Add a &lt;a href="https://llm.datasette.io/en/stable/plugins/directory.html#plugin-directory-tools"&gt;Tool plugins&lt;/a&gt; section to the plugin directory. &lt;a href="https://github.com/simonw/llm/issues/1110"&gt;#1110&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Error on &lt;code&gt;register(Klass)&lt;/code&gt; if the passed class is not a subclass of &lt;code&gt;Toolbox&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/1114"&gt;#1114&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;-h&lt;/code&gt; for &lt;code&gt;--help&lt;/code&gt; for all &lt;code&gt;llm&lt;/code&gt; CLI commands. &lt;a href="https://github.com/simonw/llm/issues/1134"&gt;#1134&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Add missing &lt;code&gt;dataclasses&lt;/code&gt; to advanced model plugins docs. &lt;a href="https://github.com/simonw/llm/issues/1137"&gt;#1137&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Fixed a bug where &lt;code&gt;llm logs -T llm_version "version" --async&lt;/code&gt; incorrectly recorded just one single log entry when it should have recorded two. &lt;a href="https://github.com/simonw/llm/issues/1150"&gt;#1150&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;All extra OpenAI model keys in &lt;code&gt;extra-openai-models.yaml&lt;/code&gt; are &lt;a href="https://llm.datasette.io/en/stable/other-models.html#openai-compatible-models"&gt;now documented&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/1228"&gt;#1228&lt;/a&gt;
&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/python"&gt;python&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/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/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm-tool-use"&gt;llm-tool-use&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gpt-5"&gt;gpt-5&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gpt"&gt;gpt&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="python"/><category term="ai"/><category term="datasette"/><category term="annotated-release-notes"/><category term="generative-ai"/><category term="llms"/><category term="llm"/><category term="llm-tool-use"/><category term="gpt-5"/><category term="gpt"/></entry><entry><title>Long context support in LLM 0.24 using fragments and template plugins</title><link href="https://simonwillison.net/2025/Apr/7/long-context-llm/" rel="alternate"/><published>2025-04-07T17:45:11+00:00</published><updated>2025-04-07T17:45:11+00:00</updated><id>https://simonwillison.net/2025/Apr/7/long-context-llm/</id><summary type="html">
    &lt;p&gt;LLM 0.24 is &lt;a href="https://llm.datasette.io/en/stable/changelog.html#v0-24"&gt;now available&lt;/a&gt; with new features to help take advantage of the increasingly long input context supported by modern LLMs.&lt;/p&gt;
&lt;p&gt;(&lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; is my command-line tool and &lt;a href="https://llm.datasette.io/en/stable/python-api.html"&gt;Python library&lt;/a&gt; for interacting with LLMs, supported by &lt;a href="https://llm.datasette.io/en/stable/plugins/directory.html"&gt;20+ plugins&lt;/a&gt; adding support for both local and remote models from a bunch of different providers.)&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Apr/7/long-context-llm/#trying-it-out"&gt;Trying it out&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Apr/7/long-context-llm/#improving-llm-s-support-for-long-context-models"&gt;Improving LLM's support for long context models&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Apr/7/long-context-llm/#asking-questions-of-llm-s-documentation"&gt;Asking questions of LLM's documentation&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Apr/7/long-context-llm/#publishing-sharing-and-reusing-templates"&gt;Publishing, sharing and reusing templates&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Apr/7/long-context-llm/#template-and-fragment-loader-plugins"&gt;Template and fragment loader plugins so far&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Apr/7/long-context-llm/#everything-else-in-llm-0-24"&gt;Everything else in LLM 0.24&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;h4 id="trying-it-out"&gt;Trying it out&lt;/h4&gt;

&lt;p&gt;To install LLM with &lt;a href="https://github.com/astral-sh/uv"&gt;uv&lt;/a&gt; (there are &lt;a href="https://llm.datasette.io/en/stable/setup.html#installation"&gt;several other options&lt;/a&gt;):&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv tool install llm&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;You'll need to either provide &lt;a href="https://platform.openai.com/api-keys"&gt;an OpenAI API key&lt;/a&gt; or install &lt;a href="https://llm.datasette.io/en/stable/plugins/directory.html"&gt;a plugin&lt;/a&gt; to use local models or  models from other providers:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm keys &lt;span class="pl-c1"&gt;set&lt;/span&gt; openai
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Paste OpenAI API key here&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;To &lt;a href="https://llm.datasette.io/en/stable/setup.html#upgrading-to-the-latest-version"&gt;upgrade LLM&lt;/a&gt; from a previous version:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install -U llm&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The biggest new feature is &lt;strong&gt;&lt;a href="https://llm.datasette.io/en/stable/fragments.html"&gt;fragments&lt;/a&gt;&lt;/strong&gt;. You can now use &lt;code&gt;-f filename&lt;/code&gt; or &lt;code&gt;-f url&lt;/code&gt; to add one or more fragments to your prompt, which means you can do things like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm -f https://simonwillison.net/2025/Apr/5/llama-4-notes/ &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;bullet point summary&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/7ffb19f647603558c575359e678185c0"&gt;the output&lt;/a&gt; from that prompt, exported using &lt;code&gt;llm logs -c --expand --usage&lt;/code&gt;. Token cost was 5,372 input, 374 output which works out as 0.103 cents (around 1/10th of a cent) using the default GPT-4o mini model.&lt;/p&gt;
&lt;p&gt;Plugins can implement custom fragment loaders with a prefix. The &lt;a href="https://github.com/simonw/llm-fragments-github"&gt;llm-fragments-github&lt;/a&gt; plugin adds a &lt;code&gt;github:&lt;/code&gt; prefix that can be used to load every text file in a GitHub repository as a list of fragments:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install llm-fragments-github
llm -f github:simonw/s3-credentials &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Suggest new features for this tool&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/c9bbbc5f6560b01f4b7882ac0194fb25"&gt;the output&lt;/a&gt;. That took 49,856 input tokens for a total cost of 0.7843 cents - nearly a whole cent!&lt;/p&gt;
&lt;h4 id="improving-llm-s-support-for-long-context-models"&gt;Improving LLM's support for long context models&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://simonwillison.net/tags/long-context/"&gt;Long context&lt;/a&gt;&lt;/strong&gt; is one of the most exciting trends in LLMs over the past eighteen months. Saturday's &lt;a href="https://simonwillison.net/2025/Apr/5/llama-4-notes/"&gt;Llama 4 Scout release&lt;/a&gt; gave us the first model with a full 10 million token context. Google's &lt;a href="https://simonwillison.net/tags/gemini/"&gt;Gemini&lt;/a&gt; family has several 1-2 million token models, and the baseline for recent models from both OpenAI and Anthropic is 100 or 200 thousand.&lt;/p&gt;
&lt;p&gt;Two years ago most models capped out at 8,000 tokens of input. Long context opens up many new interesting ways to apply this class of technology.&lt;/p&gt;
&lt;p&gt;I've been using long context models via &lt;a href="https://simonwillison.net/tags/files-to-prompt/"&gt;my files-to-prompt tool&lt;/a&gt; to summarize large codebases, explain how they work and even to &lt;a href="https://simonwillison.net/2024/Sep/25/o1-preview-llm/"&gt;debug gnarly bugs&lt;/a&gt;. As demonstrated above, it's surprisingly inexpensive to drop tens of thousands of tokens into  models like GPT-4o mini or most of the Google Gemini series, and the results are often very impressive.&lt;/p&gt;
&lt;p&gt;One of LLM's most useful features is that it &lt;a href="https://llm.datasette.io/en/stable/logging.html"&gt;logs every prompt and response&lt;/a&gt; to a SQLite database. This is great for comparing the same prompt against different models and tracking experiments over time - my own database contained thousands of responses from hundreds of different models accumulated over the past couple of years.&lt;/p&gt;
&lt;p&gt;This is where long context prompts were starting to be a problem. Since LLM stores the full prompt and response in the database, asking five questions of the same source code could result in five duplicate copies of that text in the database!&lt;/p&gt;
&lt;p&gt;The new &lt;strong&gt;fragments&lt;/strong&gt; feature targets this problem head on. Each fragment is stored once in a &lt;a href="https://llm.datasette.io/en/stable/logging.html#sql-schema"&gt;fragments&lt;/a&gt; table, then de-duplicated in the future using a SHA256 hash of its content.&lt;/p&gt;
&lt;p&gt;This saves on storage, and also enables features like &lt;code&gt;llm logs -f X&lt;/code&gt; for seeing all logged responses &lt;a href="https://llm.datasette.io/en/stable/logging.html#filtering-by-prompts-that-used-a-specific-fragment"&gt;that use a particular fragment&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Fragments can be specified in several different ways:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a path to a file&lt;/li&gt;
&lt;li&gt;a URL to data online&lt;/li&gt;
&lt;li&gt;an alias that's been set against a previous fragment (see &lt;a href="https://llm.datasette.io/en/latest/help.html#llm-fragments-set-help"&gt;llm fragments set&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;a hash ID of the content of a fragment&lt;/li&gt;
&lt;li&gt;using &lt;code&gt;prefix:argument&lt;/code&gt; to specify fragments from a plugin&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="asking-questions-of-llm-s-documentation"&gt;Asking questions of LLM's documentation&lt;/h4&gt;
&lt;p&gt;Wouldn't it be neat if LLM could answer questions about its own documentation?&lt;/p&gt;
&lt;p&gt;The new &lt;a href="https://github.com/simonw/llm-docs"&gt;llm-docs&lt;/a&gt; plugin (built with the new &lt;a href="https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-fragment-loaders-register"&gt;register_fragment_loaders() plugin hook&lt;/a&gt;) enables exactly that:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install llm-docs
llm -f docs: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;How do I embed a binary file?&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;a href="https://gist.github.com/simonw/df115ffa63e101833a100f71555bf53d"&gt;output&lt;/a&gt; starts like this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;To embed a binary file using the LLM command-line interface, you can use the &lt;code&gt;llm embed&lt;/code&gt; command with the &lt;code&gt;--binary&lt;/code&gt; option. Here’s how you can do it:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Make sure you have the appropriate embedding model installed that supports binary input.&lt;/li&gt;
&lt;li&gt;Use the following command syntax:&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm embed -m &lt;span class="pl-k"&gt;&amp;lt;&lt;/span&gt;model_id&lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt; --binary -i &lt;span class="pl-k"&gt;&amp;lt;&lt;/span&gt;path_to_your_binary_file&lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Replace &lt;code&gt;&amp;lt;model_id&amp;gt;&lt;/code&gt; with the identifier for the embedding model you want to use (e.g., &lt;code&gt;clip&lt;/code&gt; for the CLIP model) and &lt;code&gt;&amp;lt;path_to_your_binary_file&amp;gt;&lt;/code&gt; with the path to your actual binary file.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;(74,570 input, 240 output = 1.1329 cents with GPT-4o mini)&lt;/p&gt;
&lt;p&gt;Using &lt;code&gt;-f docs:&lt;/code&gt; with just the prefix is the same as using &lt;code&gt;-f docs:llm&lt;/code&gt;. The plugin fetches the documentation for your current version of LLM from my new &lt;a href="https://github.com/simonw/docs-for-llms"&gt;simonw/docs-for-llms&lt;/a&gt; repo, which also provides packaged documentation files for my &lt;code&gt;datasette&lt;/code&gt;, &lt;code&gt;s3-credentials&lt;/code&gt;, &lt;code&gt;shot-scraper&lt;/code&gt; and &lt;code&gt;sqlite-utils&lt;/code&gt; projects.&lt;/p&gt;
&lt;p&gt;Datasette's documentation has got pretty long, so you might need to run that through a Gemini model instead (using the &lt;a href="https://github.com/simonw/llm-gemini"&gt;llm-gemini plugin&lt;/a&gt;):&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm -f docs:datasette -m gemini-2.0-flash \
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Build a render_cell plugin that detects and renders markdown&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;a href="https://gist.github.com/simonw/f08b3832c379ca89d8cff3c61ec5662e"&gt;Here's the output&lt;/a&gt;. 132,042 input, 1,129 output with Gemini 2.0 Flash = 1.3656 cents.&lt;/p&gt;
&lt;p&gt;You can browse the combined documentation files this uses in &lt;a href="https://github.com/simonw/docs-for-llms"&gt;docs-for-llm&lt;/a&gt;. They're built using GitHub Actions.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://llmstxt.org/"&gt;llms-txt&lt;/a&gt; is a project lead by Jeremy Howard that encourages projects to publish similar files to help LLMs ingest a succinct copy of their documentation.&lt;/p&gt;
&lt;h4 id="publishing-sharing-and-reusing-templates"&gt;Publishing, sharing and reusing templates&lt;/h4&gt;
&lt;p&gt;The new &lt;a href="https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-template-loaders-register"&gt;register_template_loaders() plugin hook&lt;/a&gt; allows plugins to register &lt;code&gt;prefix:value&lt;/code&gt; custom template loaders, for use with the &lt;code&gt;llm -t&lt;/code&gt; option.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/llm-templates-github"&gt;llm-templates-github&lt;/a&gt; and &lt;a href="https://github.com/simonw/llm-templates-fabric"&gt;llm-templates-fabric&lt;/a&gt; are two new plugins that make use of that hook.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;llm-templates-github&lt;/code&gt; lets you share and use templates via a public GitHub repository. Here's how to run my &lt;a href="https://simonwillison.net/tags/pelican-riding-a-bicycle/"&gt;Pelican riding a bicycle&lt;/a&gt; benchmark against a specific model:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install llm-templates-github
llm -t gh:simonw/pelican-svg -m o3-mini&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This executes &lt;a href="https://github.com/simonw/llm-templates/blob/main/pelican-svg.yaml"&gt;this pelican-svg.yaml&lt;/a&gt; template stored in my &lt;a href="https://github.com/simonw/llm-templates"&gt;simonw/llm-templates&lt;/a&gt; repository, using a new repository naming convention.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;llm -t gh:simonw/pelican-svg&lt;/code&gt; will load that &lt;code&gt;pelican-svg.yaml&lt;/code&gt; file from the &lt;code&gt;simonw/llm-templates&lt;/code&gt; repo. You can also use &lt;code&gt;llm -t gh:simonw/name-of-repo/name-of-template&lt;/code&gt; to load a template from a repository that doesn't follow that convention.&lt;/p&gt;
&lt;p&gt;To share your own templates, create a repository on GitHub under your user account called &lt;code&gt;llm-templates&lt;/code&gt; and start saving &lt;code&gt;.yaml&lt;/code&gt; files to it.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/llm-templates-fabric"&gt;llm-templates-fabric&lt;/a&gt; provides a similar mechanism for loading templates from  Daniel Miessler's extensive &lt;a href="https://github.com/danielmiessler/fabric"&gt;fabric collection&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install llm-templates-fabric
curl https://simonwillison.net/2025/Apr/6/only-miffy/ &lt;span class="pl-k"&gt;|&lt;/span&gt; \
  llm -t f:extract_main_idea&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;A conversation with Daniel was the inspiration for this new plugin hook.&lt;/p&gt;

&lt;h4 id="template-and-fragment-loader-plugins"&gt;Template and fragment loader plugins so far&lt;/h4&gt;
&lt;p&gt;Here's the list of fragment and template loader plugins so far from the &lt;a href="https://llm.datasette.io/en/stable/plugins/directory.html#fragments-and-template-loaders"&gt;LLM plugins directory&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/llm-templates-github"&gt;llm-templates-github&lt;/a&gt;&lt;/strong&gt; supports loading templates shared on GitHub, e.g. &lt;code&gt;llm -t gh:simonw/pelican-svg&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/llm-templates-fabric"&gt;llm-templates-fabric&lt;/a&gt;&lt;/strong&gt; provides access to the &lt;a href="https://github.com/danielmiessler/fabric"&gt;Fabric&lt;/a&gt; collection of prompts: &lt;code&gt;cat setup.py | llm -t fabric:explain_code&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/llm-fragments-github"&gt;llm-fragments-github&lt;/a&gt;&lt;/strong&gt; can load entire GitHub repositories in a single operation: &lt;code&gt;llm -f github:simonw/files-to-prompt 'explain this code'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/llm-hacker-news"&gt;llm-hacker-news&lt;/a&gt;&lt;/strong&gt; imports conversations from Hacker News as fragments: &lt;code&gt;llm -f hn:43615912 'summary with illustrative direct quotes'&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h4 id="everything-else-in-llm-0-24"&gt;Everything else in LLM 0.24&lt;/h4&gt;
&lt;p&gt;LLM 0.24 is a big release, spanning &lt;a href="https://github.com/simonw/llm/compare/0.23...0.24"&gt;51 commits&lt;/a&gt;. The &lt;a href="https://llm.datasette.io/en/stable/changelog.html#v0-24"&gt;release notes&lt;/a&gt; cover everything that's new in full - here are a few of my highlights:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;The new &lt;a href="https://github.com/simonw/llm-openai-plugin"&gt;llm-openai plugin&lt;/a&gt; provides support for &lt;strong&gt;o1-pro&lt;/strong&gt; (which is not supported by the OpenAI mechanism used by LLM core). Future OpenAI features will migrate to this plugin instead of LLM core itself.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The problem with OpenAI models being handled by LLM core is that I have to release a whole new version of LLM every time OpenAI releases a new model or feature. Migrating this stuff out to a plugin means I can release new version of that plugin independently of LLM itself - something I frequently do for &lt;a href="https://github.com/simonw/llm-anthropic"&gt;llm-anthropic&lt;/a&gt; and &lt;a href="https://github.com/simonw/llm-gemini"&gt;llm-gemini&lt;/a&gt; and others.&lt;/p&gt;
&lt;p&gt;The new &lt;code&gt;llm-openai&lt;/code&gt; plugin uses their Responses API, a new shape of API which &lt;a href="https://simonwillison.net/2025/Mar/11/responses-vs-chat-completions/"&gt;I covered last month&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llm -t $URL&lt;/code&gt; option can now take a URL to a YAML template. &lt;a href="https://github.com/simonw/llm/issues/856"&gt;#856&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The new custom template loaders are fun, but being able to paste in a URL to a YAML file somewhere provides a simpler way to share templates.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Templates can now store default model options. &lt;a href="https://github.com/simonw/llm/issues/845"&gt;#845&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Attachments can now be stored in templates. &lt;a href="https://github.com/simonw/llm/issues/826"&gt;#826&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The quickest way to create your own template is with the &lt;code&gt;llm prompt ... --save name-of-template&lt;/code&gt; command. This now works with attachments, fragments and default model options, each of which is persisted in &lt;a href="https://llm.datasette.io/en/stable/templates.html#templates-as-yaml-files"&gt;the template YAML file&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;a href="https://llm.datasette.io/en/stable/usage.html#setting-default-options-for-models"&gt;llm models options&lt;/a&gt; family of commands for setting default options for particular models. &lt;a href="https://github.com/simonw/llm/issues/829"&gt;#829&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I built this when I &lt;a href="https://twitter.com/bindureddy/status/1900345517256958140"&gt;learned that&lt;/a&gt; Qwen's QwQ-32b model works best with temperature 0.7 and top p 0.95.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llm prompt -d path-to-sqlite.db&lt;/code&gt; option can now be used to write logs to a custom SQLite database. &lt;a href="https://github.com/simonw/llm/issues/858"&gt;#858&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This proved extremely useful for testing fragments - it meant I could run a prompt and save the full response to a separate SQLite database which I could then upload to S3 and share &lt;a href="https://github.com/simonw/llm/issues/863#issuecomment-2781723372"&gt;as a link to Datasette Lite&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llm similar -p/--plain&lt;/code&gt; option providing more human-readable output than the default JSON. &lt;a href="https://github.com/simonw/llm/issues/853"&gt;#853&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I'd like this to be the default output, but I'm holding off on changing that until LLM 1.0 since it's a breaking change for people building automations against the JSON from &lt;code&gt;llm similar&lt;/code&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Set the &lt;code&gt;LLM_RAISE_ERRORS=1&lt;/code&gt; environment variable to raise errors during prompts rather than suppressing them, which means you can run &lt;code&gt;python -i -m llm 'prompt'&lt;/code&gt; and then drop into a debugger on errors with &lt;code&gt;import pdb; pdb.pm()&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/817"&gt;#817&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Really useful for debugging new model plugins.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llm prompt -q gpt -q 4o&lt;/code&gt; option - pass &lt;code&gt;-q searchterm&lt;/code&gt; one or more times to execute a prompt against the first model that matches all of those strings - useful for if you can't remember the full model ID. &lt;a href="https://github.com/simonw/llm/issues/841"&gt;#841&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Pretty obscure but I found myself needing this. Vendors love releasing models with names like &lt;code&gt;gemini-2.5-pro-exp-03-25&lt;/code&gt;, now I can run &lt;code&gt;llm -q gem -q 2.5 -q exp 'say hi'&lt;/code&gt; to save me from looking up the model ID.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://llm.datasette.io/en/stable/other-models.html#openai-compatible-models"&gt;OpenAI compatible models&lt;/a&gt; configured using &lt;code&gt;extra-openai-models.yaml&lt;/code&gt; now support &lt;code&gt;supports_schema: true&lt;/code&gt;, &lt;code&gt;vision: true&lt;/code&gt; and &lt;code&gt;audio: true&lt;/code&gt; options. Thanks &lt;a href="https://github.com/adaitche"&gt;@adaitche&lt;/a&gt; and &lt;a href="https://github.com/giuli007"&gt;@giuli007&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/pull/819"&gt;#819&lt;/a&gt;, &lt;a href="https://github.com/simonw/llm/pull/843"&gt;#843&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I don't use this feature myself but it's clearly popular, this isn't the first time I'e had PRs with improvements from the wider community.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/plugins"&gt;plugins&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&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/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/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gemini"&gt;gemini&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/long-context"&gt;long-context&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/files-to-prompt"&gt;files-to-prompt&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="plugins"/><category term="projects"/><category term="ai"/><category term="annotated-release-notes"/><category term="openai"/><category term="generative-ai"/><category term="llms"/><category term="llm"/><category term="gemini"/><category term="long-context"/><category term="files-to-prompt"/></entry><entry><title>shot-scraper 1.8</title><link href="https://simonwillison.net/2025/Mar/25/shot-scraper/" rel="alternate"/><published>2025-03-25T01:59:38+00:00</published><updated>2025-03-25T01:59:38+00:00</updated><id>https://simonwillison.net/2025/Mar/25/shot-scraper/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/shot-scraper/releases/tag/1.8"&gt;shot-scraper 1.8&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I've added a new feature to &lt;a href="https://shot-scraper.datasette.io/"&gt;shot-scraper&lt;/a&gt; that makes it easier to share scripts for other people to use with the &lt;a href="https://shot-scraper.datasette.io/en/stable/javascript.html"&gt;shot-scraper javascript&lt;/a&gt; command.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;shot-scraper javascript&lt;/code&gt; lets you load up a web page in an invisible Chrome browser (via Playwright), execute some JavaScript against that page and output the results to your terminal. It's a fun way of running complex screen-scraping routines as part of a terminal session, or even chained together with other commands using pipes.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;-i/--input&lt;/code&gt; option lets you load that JavaScript from a file on disk - but now you can also use a &lt;code&gt;gh:&lt;/code&gt; prefix to specify loading code from GitHub instead.&lt;/p&gt;
&lt;p&gt;To quote &lt;a href="https://github.com/simonw/shot-scraper/releases/tag/1.8"&gt;the release notes&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;shot-scraper javascript&lt;/code&gt; can now optionally &lt;a href="https://shot-scraper.datasette.io/en/stable/javascript.html#running-javascript-from-github"&gt;load scripts hosted on GitHub&lt;/a&gt; via the new &lt;code&gt;gh:&lt;/code&gt; prefix to the &lt;code&gt;shot-scraper javascript -i/--input&lt;/code&gt; option. &lt;a href="https://github.com/simonw/shot-scraper/issues/173"&gt;#173&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Scripts can be referenced as &lt;code&gt;gh:username/repo/path/to/script.js&lt;/code&gt; or, if the GitHub user has created a dedicated &lt;code&gt;shot-scraper-scripts&lt;/code&gt; repository and placed scripts in the root of it, using &lt;code&gt;gh:username/name-of-script&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For example, to run this &lt;a href="https://github.com/simonw/shot-scraper-scripts/blob/main/readability.js"&gt;readability.js&lt;/a&gt; script against any web page you can use the following:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shot-scraper javascript --input gh:simonw/readability \
  https://simonwillison.net/2025/Mar/24/qwen25-vl-32b/
&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;a href="https://gist.github.com/simonw/60e196ec39a5a75dcabfd75fbe911a4c"&gt;output from that example&lt;/a&gt; starts like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
    &lt;span class="pl-ent"&gt;"title"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Qwen2.5-VL-32B: Smarter and Lighter&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"byline"&lt;/span&gt;: &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;,
    &lt;span class="pl-ent"&gt;"dir"&lt;/span&gt;: &lt;span class="pl-c1"&gt;null&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"lang"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;en-gb&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"content"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&amp;lt;div id=&lt;span class="pl-cce"&gt;\"&lt;/span&gt;readability-page-1&lt;span class="pl-cce"&gt;\"...&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;My &lt;a href="https://github.com/simonw/shot-scraper-scripts"&gt;simonw/shot-scraper-scripts&lt;/a&gt; repo only has that one file in it so far, but I'm looking forward to growing that collection and hopefully seeing other people create and share their own &lt;code&gt;shot-scraper-scripts&lt;/code&gt; repos as well.&lt;/p&gt;
&lt;p&gt;This feature is an imitation of &lt;a href="https://github.com/simonw/llm/issues/809"&gt;a similar feature&lt;/a&gt; that's coming in the next release of LLM.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/scraping"&gt;scraping&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/playwright"&gt;playwright&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/shot-scraper"&gt;shot-scraper&lt;/a&gt;&lt;/p&gt;



</summary><category term="github"/><category term="javascript"/><category term="projects"/><category term="scraping"/><category term="annotated-release-notes"/><category term="playwright"/><category term="shot-scraper"/></entry></feed>