I’m using the GitHub repository workflow to review issues and PRs opened or updated in the last five days, then I’ll separate likely 4.1 candidates from follow-up work. I won’t edit the changelog or repository.
The recent window includes several PRs that were already merged into 4.0, so I’m filtering by actual PR state and merge timing rather than treating every five-day result as a 4.1 candidate. The strongest open candidates so far are transform fixes (#785, #786), configurable SQLite variable limits (#784), and the strict-table work (#788/#787), with ANY support (#790) looking more like follow-up scope.
MCP tool call
codex_apps.github.fetch_pr
{
"repo_full_name": "simonw/sqlite-utils",
"pr_number": 785
}Action completed.
{
"pull_request": {
"url": "https://github.com/simonw/sqlite-utils/pull/785",
"number": 785,
"state": "open",
"merged": false,
"mergeable": false,
"draft": false,
"body": "When you run `.transform(types={\"age\": int})` on a table that has empty strings in a TEXT column, the empty strings currently survive as `\"\"` in the new INTEGER/FLOAT column instead of becoming NULL.\n\nThe fix wraps the SELECT expression with `NULLIF(col, '')` for any column explicitly being converted to a numeric type, so empty strings become NULL during the data copy step.\n\nText columns and columns not listed in `types` are not affected.\n\nFixes #488\r\n\r\n<!-- readthedocs-preview sqlite-utils start -->\r\n----\n📚 Documentation preview 📚: https://sqlite-utils--785.org.readthedocs.build/en/785/\n\r\n<!-- readthedocs-preview sqlite-utils end -->",
"title": "Fix transform to convert empty strings to NULL when changing to integer or float type",
"base": "main",
"base_sha": "7a52214624ae0e2c3fdf07215c1bcfc1393dbd93",
"head": "fix-transform-empty-string-to-null",
"head_sha": "d70c0e16a0dc2f37144266d0797c364e07855697",
"merge_commit_sha": "492f3ba3624c7f0545a346a2e0bc4cfd3f3b4eae",
"user": {
"login": "ikatyal2110",
"name": "ikatyal2110",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/134458944?v=4",
"id": 134458944
},
"requested_reviewers": null,
"requested_team_reviewers": null,
"diff": "@@ -2820,10 +2820,24 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n if \"rowid\" not in new_cols:\n new_cols.insert(0, \"rowid\")\n old_cols.insert(0, \"rowid\")\n+ # Columns explicitly converted to a numeric type need NULLIF(col, '') so\n+ # that empty strings stored in a previously TEXT column become NULL rather\n+ # than being coerced to 0 or raising a type error.\n+ _numeric_kws = (\"INT\", \"REAL\", \"FLOA\", \"DOUB\", \"NUMERIC\", \"DECIMAL\")\n+\n+ def _col_expr(from_, to_):\n+ if from_ in types:\n+ raw = COLUMN_TYPE_MAPPING.get(types[from_])\n+ if raw is None and isinstance(types[from_], str):\n+ raw = types[from_]\n+ if raw and any(kw in raw.upper() for kw in _numeric_kws):\n+ return \"NULLIF({}, '')\".format(quote_identifier(from_))\n+ return quote_identifier(from_)\n+\n copy_sql = \"INSERT INTO {} ({new_cols})\\n SELECT {old_cols} FROM {};\".format(\n quote_identifier(new_table_name),\n quote_identifier(self.name),\n- old_cols=\", \".join(quote_identifier(col) for col in old_cols),\n+ old_cols=\", \".join(_col_expr(f, t) for f, t in zip(old_cols, new_cols)),\n new_cols=\", \".join(quote_identifier(col) for col in new_cols),\n )\n sqls.append(copy_sql)\n@@ -21,7 +21,7 @@\n {\"types\": {\"age\": int}},\n [\n 'CREATE TABLE \"dogs_new_suffix\" (\\n \"id\" INTEGER PRIMARY KEY,\\n \"name\" TEXT,\\n \"age\" INTEGER\\n);',\n- 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", \"age\" FROM \"dogs\";',\n+ 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", NULLIF(\"age\", \\'\\') FROM \"dogs\";',\n 'DROP TABLE \"dogs\";',\n 'ALTER TABLE \"dogs_new_suffix\" RENAME TO \"dogs\";',\n ],\n@@ -51,7 +51,7 @@\n {\"types\": {\"age\": int}, \"rename\": {\"age\": \"dog_age\"}},\n [\n 'CREATE TABLE \"dogs_new_suffix\" (\\n \"id\" INTEGER PRIMARY KEY,\\n \"name\" TEXT,\\n \"dog_age\" INTEGER\\n);',\n- 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"dog_age\")\\n SELECT \"rowid\", \"id\", \"name\", \"age\" FROM \"dogs\";',\n+ 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"dog_age\")\\n SELECT \"rowid\", \"id\", \"name\", NULLIF(\"age\", \\'\\') FROM \"dogs\";',\n 'DROP TABLE \"dogs\";',\n 'ALTER TABLE \"dogs_new_suffix\" RENAME TO \"dogs\";',\n ],\n@@ -144,7 +144,7 @@ def tracer(sql, params):\n {\"types\": {\"age\": int}},\n [\n 'CREATE TABLE \"dogs_new_suffix\" (\\n \"id\" INTEGER,\\n \"name\" TEXT,\\n \"age\" INTEGER\\n);',\n- 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", \"age\" FROM \"dogs\";',\n+ 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", NULLIF(\"age\", \\'\\') FROM \"dogs\";',\n 'DROP TABLE \"dogs\";',\n 'ALTER TABLE \"dogs_new_suffix\" RENAME TO \"dogs\";',\n ],\n@@ -669,6 +669,20 @@ def test_transform_with_indexes_errors(fresh_db, transform_params):\n )\n \n \n+def test_transform_converts_empty_strings_to_null_for_numeric_types(fresh_db):\n+ # Regression test for: transform should convert '' to NULL when changing\n+ # a TEXT column to an INTEGER or FLOAT type (issue #488).\n+ fresh_db[\"test\"].insert_all([\n+ {\"id\": \"1\", \"age\": \"3\", \"weight\": \"2.5\", \"name\": \"Alice\"},\n+ {\"id\": \"2\", \"age\": \"\", \"weight\": \"\", \"name\": \"\"},\n+ ])\n+ fresh_db[\"test\"].transform(types={\"age\": int, \"weight\": float})\n+ rows = list(fresh_db[\"test\"].rows)\n+ assert rows[0] == {\"id\": \"1\", \"age\": 3, \"weight\": 2.5, \"name\": \"Alice\"}\n+ # Empty strings in numeric columns become NULL; text columns are unchanged\n+ assert rows[1] == {\"id\": \"2\", \"age\": None, \"weight\": None, \"name\": \"\"}\n+\n+\n def test_transform_with_unique_constraint_implicit_index(fresh_db):\n dogs = fresh_db[\"dogs\"]\n # Create a table with a UNIQUE constraint on 'name', which creates an implicit index",
"comments": [],
"created_at": "2026-07-08T22:22:13Z",
"updated_at": "2026-07-08T22:22:26Z",
"closed_at": null,
"merged_at": null,
"commits": 1,
"changed_files": 2,
"additions": 32,
"deletions": 4,
"display_url": "https://github.com/simonw/sqlite-utils/pull/785",
"display_title": "Fix transform to convert empty strings to NULL when changing to integer or float type"
},
"url": "https://github.com/simonw/sqlite-utils/pull/785",
"title": "Fix transform to convert empty strings to NULL when changing to integer or float type",
"diff": "@@ -2820,10 +2820,24 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n if \"rowid\" not in new_cols:\n new_cols.insert(0, \"rowid\")\n old_cols.insert(0, \"rowid\")\n+ # Columns explicitly converted to a numeric type need NULLIF(col, '') so\n+ # that empty strings stored in a previously TEXT column become NULL rather\n+ # than being coerced to 0 or raising a type error.\n+ _numeric_kws = (\"INT\", \"REAL\", \"FLOA\", \"DOUB\", \"NUMERIC\", \"DECIMAL\")\n+\n+ def _col_expr(from_, to_):\n+ if from_ in types:\n+ raw = COLUMN_TYPE_MAPPING.get(types[from_])\n+ if raw is None and isinstance(types[from_], str):\n+ raw = types[from_]\n+ if raw and any(kw in raw.upper() for kw in _numeric_kws):\n+ return \"NULLIF({}, '')\".format(quote_identifier(from_))\n+ return quote_identifier(from_)\n+\n copy_sql = \"INSERT INTO {} ({new_cols})\\n SELECT {old_cols} FROM {};\".format(\n quote_identifier(new_table_name),\n quote_identifier(self.name),\n- old_cols=\", \".join(quote_identifier(col) for col in old_cols),\n+ old_cols=\", \".join(_col_expr(f, t) for f, t in zip(old_cols, new_cols)),\n new_cols=\", \".join(quote_identifier(col) for col in new_cols),\n )\n sqls.append(copy_sql)\n@@ -21,7 +21,7 @@\n {\"types\": {\"age\": int}},\n [\n 'CREATE TABLE \"dogs_new_suffix\" (\\n \"id\" INTEGER PRIMARY KEY,\\n \"name\" TEXT,\\n \"age\" INTEGER\\n);',\n- 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", \"age\" FROM \"dogs\";',\n+ 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", NULLIF(\"age\", \\'\\') FROM \"dogs\";',\n 'DROP TABLE \"dogs\";',\n 'ALTER TABLE \"dogs_new_suffix\" RENAME TO \"dogs\";',\n ],\n@@ -51,7 +51,7 @@\n {\"types\": {\"age\": int}, \"rename\": {\"age\": \"dog_age\"}},\n [\n 'CREATE TABLE \"dogs_new_suffix\" (\\n \"id\" INTEGER PRIMARY KEY,\\n \"name\" TEXT,\\n \"dog_age\" INTEGER\\n);',\n- 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"dog_age\")\\n SELECT \"rowid\", \"id\", \"name\", \"age\" FROM \"dogs\";',\n+ 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"dog_age\")\\n SELECT \"rowid\", \"id\", \"name\", NULLIF(\"age\", \\'\\') FROM \"dogs\";',\n 'DROP TABLE \"dogs\";',\n 'ALTER TABLE \"dogs_new_suffix\" RENAME TO \"dogs\";',\n ],\n@@ -144,7 +144,7 @@ def tracer(sql, params):\n {\"types\": {\"age\": int}},\n [\n 'CREATE TABLE \"dogs_new_suffix\" (\\n \"id\" INTEGER,\\n \"name\" TEXT,\\n \"age\" INTEGER\\n);',\n- 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", \"age\" FROM \"dogs\";',\n+ 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", NULLIF(\"age\", \\'\\') FROM \"dogs\";',\n 'DROP TABLE \"dogs\";',\n 'ALTER TABLE \"dogs_new_suffix\" RENAME TO \"dogs\";',\n ],\n@@ -669,6 +669,20 @@ def test_transform_with_indexes_errors(fresh_db, transform_params):\n )\n \n \n+def test_transform_converts_empty_strings_to_null_for_numeric_types(fresh_db):\n+ # Regression test for: transform should convert '' to NULL when changing\n+ # a TEXT column to an INTEGER or FLOAT type (issue #488).\n+ fresh_db[\"test\"].insert_all([\n+ {\"id\": \"1\", \"age\": \"3\", \"weight\": \"2.5\", \"name\": \"Alice\"},\n+ {\"id\": \"2\", \"age\": \"\", \"weight\": \"\", \"name\": \"\"},\n+ ])\n+ fresh_db[\"test\"].transform(types={\"age\": int, \"weight\": float})\n+ rows = list(fresh_db[\"test\"].rows)\n+ assert rows[0] == {\"id\": \"1\", \"age\": 3, \"weight\": 2.5, \"name\": \"Alice\"}\n+ # Empty strings in numeric columns become NULL; text columns are unchanged\n+ assert rows[1] == {\"id\": \"2\", \"age\": None, \"weight\": None, \"name\": \"\"}\n+\n+\n def test_transform_with_unique_constraint_implicit_index(fresh_db):\n dogs = fresh_db[\"dogs\"]\n # Create a table with a UNIQUE constraint on 'name', which creates an implicit index",
"comments": null,
"display_url": "https://github.com/simonw/sqlite-utils/pull/785",
"display_title": "Fix transform to convert empty strings to NULL when changing to integer or float type"
}MCP tool call
codex_apps.github.fetch_issue
{
"repository_full_name": "simonw/sqlite-utils",
"issue_number": 790
}Action completed.
{
"issue": {
"url": "https://github.com/simonw/sqlite-utils/issues/790",
"title": "Support ANY column types for strict tables",
"issue_number": 790,
"body": "> We don't have any mechanism to support `ANY` columns at the moment. Open question how to deal with that. Options include:\r\n>\r\n> - Ignore the problem entirely\r\n> - Add a `sqlite_utils.ANY` constant which can be used in create table calls, e.g. `db.create_table(\"t\", {\"id\": int, \"name\": str, \"misc\": sqlite_utils.ANY})` - would have to be handled in `add_column()` and `transform()` and a bunch of other places too.\r\n> - Don't support them in create_table/etc but DO support them in introspection, since that's part of how `transform()` works\r\n\r\n_Originally posted by @simonw in https://github.com/simonw/sqlite-utils/issues/788#issuecomment-4948493701_",
"state": "open",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"assignees": null,
"labels": [
{
"name": "enhancement",
"color": "a2eeef",
"description": "New feature or request"
}
],
"milestone": null,
"state_reason": null,
"comments": 0,
"created_at": "2026-07-11T23:30:33Z",
"updated_at": "2026-07-11T23:33:30Z",
"closed_at": null,
"display_url": "https://github.com/simonw/sqlite-utils/issues/790",
"display_title": "Support ANY column types for strict tables"
},
"url": "https://github.com/simonw/sqlite-utils/issues/790",
"title": "Support ANY column types for strict tables",
"display_url": "https://github.com/simonw/sqlite-utils/issues/790",
"display_title": "Support ANY column types for strict tables"
}MCP tool call
codex_apps.github.fetch_pr
{
"repo_full_name": "simonw/sqlite-utils",
"pr_number": 784
}Action completed.
{
"pull_request": {
"url": "https://github.com/simonw/sqlite-utils/pull/784",
"number": 784,
"state": "open",
"merged": false,
"mergeable": false,
"draft": false,
"body": "Refs #147.\n\n`SQLITE_MAX_VARS` is hard-coded to 999, which caps how many rows `insert_all` batches into a single INSERT. Many SQLite builds are compiled with a much higher `SQLITE_MAX_VARIABLE_NUMBER` (e.g. 250,000 on Debian/Ubuntu, 500,000 on recent macOS), so on those systems the 999 cap forces many more, smaller batches than necessary.\n\nAs suggested in the issue, this adds an optional `sqlite_max_vars` argument to the `Database` constructor:\n\n```python\ndb = Database(\"data.db\", sqlite_max_vars=250_000)\n```\n\n- Default behaviour is unchanged: when the argument is not given, the limit falls back to the module-level `SQLITE_MAX_VARS` (999).\n- A `Database.sqlite_max_vars` property exposes the effective value.\n- `Table.insert_all` uses it in both places that previously referenced the module global: the column-count guard and the batch-size calculation.\n\nI deliberately kept this to the constructor argument only, and did not add automatic detection of the compiled limit — that would change default batching for everyone and is a larger, separate change.\n\nDocs updated in `docs/python-api.rst`; tests added in `tests/test_create.py` (default stays 999, a raised value produces fewer INSERT batches as measured via the `tracer` hook, and the column-count error message reflects the custom value).\n\r\n\r\n<!-- readthedocs-preview sqlite-utils start -->\r\n----\n📚 Documentation preview 📚: https://sqlite-utils--784.org.readthedocs.build/en/784/\n\r\n<!-- readthedocs-preview sqlite-utils end -->",
"title": "Allow SQLITE_MAX_VARS to be customized via Database(sqlite_max_vars=...)",
"base": "main",
"base_sha": "7a52214624ae0e2c3fdf07215c1bcfc1393dbd93",
"head": "feature/sqlite-max-vars-configurable",
"head_sha": "7c01b8d58831b94b6885d707cdab6cc168977364",
"merge_commit_sha": "922bc89af1218b8e85eff603de8b84bfae67b5be",
"user": {
"login": "AmadNaseem",
"name": "AmadNaseem",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/45733533?v=4",
"id": 45733533
},
"requested_reviewers": null,
"requested_team_reviewers": null,
"diff": "@@ -1026,6 +1026,12 @@ The function can accept an iterator or generator of rows and will commit them ac\n \"name\": \"Name {}\".format(i),\n } for i in range(10000)), batch_size=1000)\n \n+The largest batch that will actually be sent to SQLite is limited by the maximum number of SQL variables allowed in a single query, which defaults to 999. If your copy of SQLite was compiled with a higher ``SQLITE_MAX_VARIABLE_NUMBER`` you can tell ``sqlite-utils`` to use larger batches - and hence run faster - by passing ``sqlite_max_vars=`` to the ``Database()`` constructor:\n+\n+.. code-block:: python\n+\n+ db = Database(\"big.db\", sqlite_max_vars=100_000)\n+\n You can skip inserting any records that have a primary key that already exists using ``ignore=True``. This works with both ``.insert({...}, ignore=True)`` and ``.insert_all([...], ignore=True)``.\n \n You can delete all the existing rows in the table before inserting the new records using ``truncate=True``. This is useful if you want to replace the data in the table.\n@@ -504,6 +504,9 @@ class Database:\n :param use_old_upsert: set to ``True`` to force the older upsert implementation. See\n :ref:`python_api_old_upsert`\n :param strict: Apply STRICT mode to all created tables (unless overridden)\n+ :param sqlite_max_vars: Maximum number of SQL variables to use in a single query. Defaults\n+ to ``sqlite_utils.db.SQLITE_MAX_VARS`` (999). Increase this if your SQLite was compiled\n+ with a higher ``SQLITE_MAX_VARIABLE_NUMBER`` to allow larger insert batches\n \"\"\"\n \n _counts_table_name = \"_counts\"\n@@ -522,10 +525,12 @@ def __init__(\n execute_plugins: bool = True,\n use_old_upsert: bool = False,\n strict: bool = False,\n+ sqlite_max_vars: Optional[int] = None,\n ):\n self.memory_name = None\n self.memory = False\n self.use_old_upsert = use_old_upsert\n+ self._sqlite_max_vars = sqlite_max_vars\n if not (\n (filename_or_conn is not None and (not memory and not memory_name))\n or (filename_or_conn is None and (memory or memory_name))\n@@ -579,6 +584,17 @@ def __init__(\n pm.hook.prepare_connection(conn=self.conn)\n self.strict = strict\n \n+ @property\n+ def sqlite_max_vars(self) -> int:\n+ \"\"\"\n+ The maximum number of SQL variables to use in a single query. This is the value\n+ passed as ``sqlite_max_vars=`` to the constructor, or the\n+ ``sqlite_utils.db.SQLITE_MAX_VARS`` default of 999 if that was not set.\n+ \"\"\"\n+ if self._sqlite_max_vars is not None:\n+ return self._sqlite_max_vars\n+ return SQLITE_MAX_VARS\n+\n def __enter__(self) -> \"Database\":\n return self\n \n@@ -4436,14 +4452,11 @@ def insert_all(\n first_record = cast(Dict[str, Any], first_record)\n num_columns = len(first_record.keys())\n \n- if num_columns > SQLITE_MAX_VARS:\n- raise ValueError(\n- \"Rows can have a maximum of {} columns\".format(SQLITE_MAX_VARS)\n- )\n+ max_vars = self.db.sqlite_max_vars\n+ if num_columns > max_vars:\n+ raise ValueError(\"Rows can have a maximum of {} columns\".format(max_vars))\n batch_size = (\n- 1\n- if num_columns == 0\n- else max(1, min(batch_size, SQLITE_MAX_VARS // num_columns))\n+ 1 if num_columns == 0 else max(1, min(batch_size, max_vars // num_columns))\n )\n self.last_rowid = None\n self.last_pk = None\n@@ -695,6 +695,39 @@ def test_bulk_insert_more_than_999_values(fresh_db):\n assert fresh_db[\"big\"].count == 100\n \n \n+def test_sqlite_max_vars_defaults_to_999():\n+ # https://github.com/simonw/sqlite-utils/issues/147\n+ assert Database(memory=True).sqlite_max_vars == 999\n+\n+\n+def test_sqlite_max_vars_can_be_customized():\n+ # https://github.com/simonw/sqlite-utils/issues/147\n+ assert Database(memory=True, sqlite_max_vars=100000).sqlite_max_vars == 100000\n+ # A raised limit should allow a bigger batch, so the same records are\n+ # written using fewer INSERT statements\n+ records = [{\"c{}\".format(i): i for i in range(5)} for _ in range(500)]\n+\n+ def count_inserts(sqlite_max_vars):\n+ seen = []\n+ db = Database(\n+ memory=True,\n+ sqlite_max_vars=sqlite_max_vars,\n+ tracer=lambda sql, params: seen.append(sql),\n+ )\n+ db[\"t\"].insert_all(records, batch_size=100000)\n+ return len([sql for sql in seen if sql.strip().upper().startswith(\"INSERT\")])\n+\n+ assert count_inserts(100000) == 1\n+ assert count_inserts(None) > 1\n+\n+\n+def test_error_message_uses_custom_sqlite_max_vars():\n+ # https://github.com/simonw/sqlite-utils/issues/147\n+ db = Database(memory=True, sqlite_max_vars=10)\n+ with pytest.raises(ValueError, match=\"maximum of 10 columns\"):\n+ db[\"big\"].insert({\"c{}\".format(i): i for i in range(11)})\n+\n+\n @pytest.mark.parametrize(\n \"num_columns,should_error\", ((900, False), (999, False), (1000, True))\n )",
"comments": [],
"created_at": "2026-07-08T08:53:44Z",
"updated_at": "2026-07-08T08:53:56Z",
"closed_at": null,
"merged_at": null,
"commits": 1,
"changed_files": 3,
"additions": 59,
"deletions": 7,
"display_url": "https://github.com/simonw/sqlite-utils/pull/784",
"display_title": "Allow SQLITE_MAX_VARS to be customized via Database(sqlite_max_vars=...)"
},
"url": "https://github.com/simonw/sqlite-utils/pull/784",
"title": "Allow SQLITE_MAX_VARS to be customized via Database(sqlite_max_vars=...)",
"diff": "@@ -1026,6 +1026,12 @@ The function can accept an iterator or generator of rows and will commit them ac\n \"name\": \"Name {}\".format(i),\n } for i in range(10000)), batch_size=1000)\n \n+The largest batch that will actually be sent to SQLite is limited by the maximum number of SQL variables allowed in a single query, which defaults to 999. If your copy of SQLite was compiled with a higher ``SQLITE_MAX_VARIABLE_NUMBER`` you can tell ``sqlite-utils`` to use larger batches - and hence run faster - by passing ``sqlite_max_vars=`` to the ``Database()`` constructor:\n+\n+.. code-block:: python\n+\n+ db = Database(\"big.db\", sqlite_max_vars=100_000)\n+\n You can skip inserting any records that have a primary key that already exists using ``ignore=True``. This works with both ``.insert({...}, ignore=True)`` and ``.insert_all([...], ignore=True)``.\n \n You can delete all the existing rows in the table before inserting the new records using ``truncate=True``. This is useful if you want to replace the data in the table.\n@@ -504,6 +504,9 @@ class Database:\n :param use_old_upsert: set to ``True`` to force the older upsert implementation. See\n :ref:`python_api_old_upsert`\n :param strict: Apply STRICT mode to all created tables (unless overridden)\n+ :param sqlite_max_vars: Maximum number of SQL variables to use in a single query. Defaults\n+ to ``sqlite_utils.db.SQLITE_MAX_VARS`` (999). Increase this if your SQLite was compiled\n+ with a higher ``SQLITE_MAX_VARIABLE_NUMBER`` to allow larger insert batches\n \"\"\"\n \n _counts_table_name = \"_counts\"\n@@ -522,10 +525,12 @@ def __init__(\n execute_plugins: bool = True,\n use_old_upsert: bool = False,\n strict: bool = False,\n+ sqlite_max_vars: Optional[int] = None,\n ):\n self.memory_name = None\n self.memory = False\n self.use_old_upsert = use_old_upsert\n+ self._sqlite_max_vars = sqlite_max_vars\n if not (\n (filename_or_conn is not None and (not memory and not memory_name))\n or (filename_or_conn is None and (memory or memory_name))\n@@ -579,6 +584,17 @@ def __init__(\n pm.hook.prepare_connection(conn=self.conn)\n self.strict = strict\n \n+ @property\n+ def sqlite_max_vars(self) -> int:\n+ \"\"\"\n+ The maximum number of SQL variables to use in a single query. This is the value\n+ passed as ``sqlite_max_vars=`` to the constructor, or the\n+ ``sqlite_utils.db.SQLITE_MAX_VARS`` default of 999 if that was not set.\n+ \"\"\"\n+ if self._sqlite_max_vars is not None:\n+ return self._sqlite_max_vars\n+ return SQLITE_MAX_VARS\n+\n def __enter__(self) -> \"Database\":\n return self\n \n@@ -4436,14 +4452,11 @@ def insert_all(\n first_record = cast(Dict[str, Any], first_record)\n num_columns = len(first_record.keys())\n \n- if num_columns > SQLITE_MAX_VARS:\n- raise ValueError(\n- \"Rows can have a maximum of {} columns\".format(SQLITE_MAX_VARS)\n- )\n+ max_vars = self.db.sqlite_max_vars\n+ if num_columns > max_vars:\n+ raise ValueError(\"Rows can have a maximum of {} columns\".format(max_vars))\n batch_size = (\n- 1\n- if num_columns == 0\n- else max(1, min(batch_size, SQLITE_MAX_VARS // num_columns))\n+ 1 if num_columns == 0 else max(1, min(batch_size, max_vars // num_columns))\n )\n self.last_rowid = None\n self.last_pk = None\n@@ -695,6 +695,39 @@ def test_bulk_insert_more_than_999_values(fresh_db):\n assert fresh_db[\"big\"].count == 100\n \n \n+def test_sqlite_max_vars_defaults_to_999():\n+ # https://github.com/simonw/sqlite-utils/issues/147\n+ assert Database(memory=True).sqlite_max_vars == 999\n+\n+\n+def test_sqlite_max_vars_can_be_customized():\n+ # https://github.com/simonw/sqlite-utils/issues/147\n+ assert Database(memory=True, sqlite_max_vars=100000).sqlite_max_vars == 100000\n+ # A raised limit should allow a bigger batch, so the same records are\n+ # written using fewer INSERT statements\n+ records = [{\"c{}\".format(i): i for i in range(5)} for _ in range(500)]\n+\n+ def count_inserts(sqlite_max_vars):\n+ seen = []\n+ db = Database(\n+ memory=True,\n+ sqlite_max_vars=sqlite_max_vars,\n+ tracer=lambda sql, params: seen.append(sql),\n+ )\n+ db[\"t\"].insert_all(records, batch_size=100000)\n+ return len([sql for sql in seen if sql.strip().upper().startswith(\"INSERT\")])\n+\n+ assert count_inserts(100000) == 1\n+ assert count_inserts(None) > 1\n+\n+\n+def test_error_message_uses_custom_sqlite_max_vars():\n+ # https://github.com/simonw/sqlite-utils/issues/147\n+ db = Database(memory=True, sqlite_max_vars=10)\n+ with pytest.raises(ValueError, match=\"maximum of 10 columns\"):\n+ db[\"big\"].insert({\"c{}\".format(i): i for i in range(11)})\n+\n+\n @pytest.mark.parametrize(\n \"num_columns,should_error\", ((900, False), (999, False), (1000, True))\n )",
"comments": null,
"display_url": "https://github.com/simonw/sqlite-utils/pull/784",
"display_title": "Allow SQLITE_MAX_VARS to be customized via Database(sqlite_max_vars=...)"
}MCP tool call
codex_apps.github.fetch_pr
{
"repo_full_name": "simonw/sqlite-utils",
"pr_number": 788
}Action completed.
{
"pull_request": {
"url": "https://github.com/simonw/sqlite-utils/pull/788",
"number": 788,
"state": "open",
"merged": false,
"mergeable": true,
"draft": true,
"body": "Refs:\r\n- #787\r\n\r\n<!-- readthedocs-preview sqlite-utils start -->\r\n----\n📚 Documentation preview 📚: https://sqlite-utils--788.org.readthedocs.build/en/788/\n\r\n<!-- readthedocs-preview sqlite-utils end -->",
"title": ".transform(strict=) and sqlite-utils transform --strict/--no-strict",
"base": "main",
"base_sha": "6531a57863ce23d502e504fd8fcd375fbe5cbb7f",
"head": "transform-strict",
"head_sha": "989729d5ed145092637385e9c426cc5ff80f7a10",
"merge_commit_sha": "ca37e4e0b0f0541d621f0af6635c1da97240315b",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"requested_reviewers": null,
"requested_team_reviewers": null,
"diff": "@@ -9,6 +9,8 @@\n Unreleased\n ----------\n \n+- ``table.transform()`` and ``table.transform_sql()`` now accept ``strict=True`` or ``strict=False`` to change a table's SQLite strict mode. Omitting the option, or passing ``strict=None``, preserves the existing mode. (:issue:`787`)\n+- The ``sqlite-utils transform`` command now accepts ``--strict`` and ``--no-strict`` to change a table's SQLite strict mode. Omitting both options preserves the existing mode. (:issue:`787`)\n - ``sqlite-utils query`` can now read the SQL query from standard input by passing ``-`` in place of the query, for example ``echo \"select * from dogs\" | sqlite-utils query dogs.db -``. (:issue:`765`)\n - ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for :ref:`providing a block of Python code <cli_insert_code>` (or a path to a ``.py`` file) that defines a ``rows()`` function or ``rows`` iterable of rows to insert, as an alternative to importing from a file. (:issue:`684`)\n - ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept ``--type column-name type`` to :ref:`override the type automatically chosen when the table is created <cli_insert_csv_tsv_column_types>`. This is useful for CSV or TSV columns such as ZIP codes that look like integers but should be stored as ``TEXT`` to preserve leading zeros. (:issue:`131`)\n@@ -508,6 +508,8 @@ See :ref:`cli_transform_table`.\n Add a foreign key constraint from a column to\n another table with another column\n --drop-foreign-key TEXT Drop foreign key constraint for this column\n+ --strict / --no-strict Enable or disable STRICT mode (default:\n+ preserve current mode)\n --sql Output SQL without executing it\n --load-extension TEXT Path to SQLite extension, with optional\n :entrypoint\n@@ -2182,7 +2182,7 @@ Use ``--ignore`` to ignore the error if the table does not exist.\n Transforming tables\n ===================\n \n-The ``transform`` command allows you to apply complex transformations to a table that cannot be implemented using a regular SQLite ``ALTER TABLE`` command. See :ref:`python_api_transform` for details of how this works. The ``transform`` command preserves a table's ``STRICT`` mode.\n+The ``transform`` command allows you to apply complex transformations to a table that cannot be implemented using a regular SQLite ``ALTER TABLE`` command. See :ref:`python_api_transform` for details of how this works. By default, the ``transform`` command preserves a table's ``STRICT`` mode.\n \n .. code-block:: bash\n \n@@ -2228,6 +2228,12 @@ Every option for this table (with the exception of ``--pk-none``) can be specifi\n ``--add-foreign-key column other_table other_column``\n Add a foreign key constraint to ``column`` pointing to ``other_table.other_column``.\n \n+``--strict``\n+ Convert the table to a `SQLite STRICT table <https://www.sqlite.org/stricttables.html>`__. The command fails if the available SQLite version does not support strict tables. If existing rows contain values that are incompatible with their declared column types the transformation fails and the original table is left unchanged.\n+\n+``--no-strict``\n+ Convert a strict table back to a regular non-strict table.\n+\n If you want to see the SQL that will be executed to make the change without actually executing it, add the ``--sql`` flag. For example:\n \n .. code-block:: bash\n@@ -1753,6 +1753,29 @@ To alter the type of a column, use the ``types=`` argument:\n \n See :ref:`python_api_add_column` for a list of available types.\n \n+.. _python_api_transform_strict:\n+\n+Changing strict mode\n+--------------------\n+\n+The optional ``strict=`` parameter can change whether a table uses `SQLite STRICT mode <https://www.sqlite.org/stricttables.html>`__. Pass ``strict=True`` to convert a regular table to a strict table:\n+\n+.. code-block:: python\n+\n+ table.transform(strict=True)\n+\n+Pass ``strict=False`` to convert a strict table back to a regular non-strict table:\n+\n+.. code-block:: python\n+\n+ table.transform(strict=False)\n+\n+The default is ``strict=None``, which preserves the table's existing strict mode.\n+\n+Passing ``strict=True`` raises ``sqlite_utils.db.TransformError`` if the available SQLite version does not support strict tables.\n+\n+Converting to a strict table validates all existing rows as they are copied into the replacement table. If a value is incompatible with its declared column type, SQLite raises ``sqlite3.IntegrityError`` and the transformation is rolled back, leaving the original table and its data unchanged.\n+\n .. _python_api_transform_rename_columns:\n \n Renaming columns\n@@ -2718,6 +2718,11 @@ def schema(\n multiple=True,\n help=\"Drop foreign key constraint for this column\",\n )\n+@click.option(\n+ \"--strict/--no-strict\",\n+ default=None,\n+ help=\"Enable or disable STRICT mode (default: preserve current mode)\",\n+)\n @click.option(\"--sql\", is_flag=True, help=\"Output SQL without executing it\")\n @load_extension_option\n def transform(\n@@ -2735,6 +2740,7 @@ def transform(\n default_none,\n add_foreign_keys,\n drop_foreign_keys,\n+ strict,\n sql,\n load_extension,\n ):\n@@ -2796,6 +2802,7 @@ def transform(\n defaults=default_dict,\n drop_foreign_keys=drop_foreign_keys_value,\n add_foreign_keys=add_foreign_keys_value,\n+ strict=strict,\n ):\n click.echo(line)\n else:\n@@ -2809,6 +2816,7 @@ def transform(\n defaults=default_dict,\n drop_foreign_keys=drop_foreign_keys_value,\n add_foreign_keys=add_foreign_keys_value,\n+ strict=strict,\n )\n \n \n@@ -2514,6 +2514,7 @@ def transform(\n foreign_keys: Optional[ForeignKeysType] = None,\n column_order: Optional[List[str]] = None,\n keep_table: Optional[str] = None,\n+ strict: Optional[bool] = None,\n ) -> \"Table\":\n \"\"\"\n Apply an advanced alter table, including operations that are not supported by\n@@ -2536,6 +2537,8 @@ def transform(\n to use when creating the table\n :param keep_table: If specified, the existing table will be renamed to this and will not be\n dropped\n+ :param strict: Set to ``True`` to make the table strict or ``False`` to make it\n+ non-strict. Defaults to ``None``, which preserves the existing strict mode.\n \"\"\"\n if not self.exists():\n raise ValueError(\"Cannot transform a table that doesn't exist yet\")\n@@ -2551,6 +2554,7 @@ def transform(\n foreign_keys=foreign_keys,\n column_order=column_order,\n keep_table=keep_table,\n+ strict=strict,\n )\n pragma_foreign_keys_was_on = bool(\n self.db.execute(\"PRAGMA foreign_keys\").fetchone()[0]\n@@ -2587,6 +2591,8 @@ def transform(\n self.db.execute(\"PRAGMA defer_foreign_keys=OFF;\")\n if should_disable_foreign_keys:\n self.db.execute(\"PRAGMA foreign_keys=1;\")\n+ if strict is not None:\n+ self._defaults[\"strict\"] = strict\n return self\n \n def transform_sql(\n@@ -2604,6 +2610,7 @@ def transform_sql(\n column_order: Optional[List[str]] = None,\n tmp_suffix: Optional[str] = None,\n keep_table: Optional[str] = None,\n+ strict: Optional[bool] = None,\n ) -> List[str]:\n \"\"\"\n Return a list of SQL statements that should be executed in order to apply this transformation.\n@@ -2624,7 +2631,11 @@ def transform_sql(\n :param tmp_suffix: Suffix to use for the temporary table name\n :param keep_table: If specified, the existing table will be renamed to this and will not be\n dropped\n+ :param strict: Set to ``True`` to make the table strict or ``False`` to make it\n+ non-strict. Defaults to ``None``, which preserves the existing strict mode.\n \"\"\"\n+ if strict is True and not self.db.supports_strict:\n+ raise TransformError(\"SQLite does not support STRICT tables\")\n types = types or {}\n rename = rename or {}\n drop = drop or set()\n@@ -2806,7 +2817,7 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n defaults=create_table_defaults,\n foreign_keys=create_table_foreign_keys,\n column_order=column_order,\n- strict=self.strict,\n+ strict=self.strict if strict is None else strict,\n ).strip()\n )\n \n@@ -3,6 +3,7 @@\n from click.testing import CliRunner\n from pathlib import Path\n import subprocess\n+import sqlite3\n import sys\n import json\n import os\n@@ -1939,6 +1940,64 @@ def test_transform_sql(db_path):\n assert db[\"dogs\"].schema == original_schema\n \n \n+@pytest.mark.parametrize(\n+ \"initial_strict,args,expected_strict\",\n+ (\n+ (False, [], False),\n+ (True, [], True),\n+ (False, [\"--strict\"], True),\n+ (True, [\"--no-strict\"], False),\n+ ),\n+)\n+def test_transform_strict_option(db_path, initial_strict, args, expected_strict):\n+ db = Database(db_path)\n+ if not db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ db[\"dogs\"].create({\"id\": int}, strict=initial_strict)\n+\n+ result = CliRunner().invoke(cli.cli, [\"transform\", db_path, \"dogs\"] + args)\n+\n+ assert result.exit_code == 0, result.output\n+ assert db[\"dogs\"].strict is expected_strict\n+\n+\n+@pytest.mark.parametrize(\n+ \"initial_strict,flag,sql_is_strict\",\n+ (\n+ (False, \"--strict\", True),\n+ (True, \"--no-strict\", False),\n+ ),\n+)\n+def test_transform_strict_option_sql(db_path, initial_strict, flag, sql_is_strict):\n+ db = Database(db_path)\n+ if not db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ db[\"dogs\"].create({\"id\": int}, strict=initial_strict)\n+\n+ result = CliRunner().invoke(cli.cli, [\"transform\", db_path, \"dogs\", flag, \"--sql\"])\n+\n+ assert result.exit_code == 0, result.output\n+ assert (\") STRICT;\" in result.output) is sql_is_strict\n+ assert db[\"dogs\"].strict is initial_strict\n+\n+\n+def test_transform_strict_option_with_invalid_data(db_path):\n+ db = Database(db_path)\n+ if not db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ dogs = db[\"dogs\"]\n+ dogs.create({\"id\": int})\n+ dogs.insert({\"id\": \"not-an-integer\"})\n+\n+ result = CliRunner().invoke(cli.cli, [\"transform\", db_path, \"dogs\", \"--strict\"])\n+\n+ assert result.exit_code == 1\n+ assert isinstance(result.exception, sqlite3.IntegrityError)\n+ assert dogs.strict is False\n+ assert list(dogs.rows) == [{\"id\": \"not-an-integer\"}]\n+ assert not any(name.startswith(\"dogs_new_\") for name in db.table_names())\n+\n+\n @pytest.mark.parametrize(\n \"extra_args,expected_schema\",\n (\n@@ -1,3 +1,5 @@\n+import sqlite3\n+\n from sqlite_utils.db import ForeignKey, TransformError\n from sqlite_utils.utils import OperationalError\n import pytest\n@@ -566,13 +568,63 @@ def test_transform_preserves_rowids(fresh_db, table_type):\n assert previous_rows == next_rows\n \n \n-@pytest.mark.parametrize(\"strict\", (False, True))\n-def test_transform_strict(fresh_db, strict):\n- dogs = fresh_db.table(\"dogs\", strict=strict)\n+@pytest.mark.parametrize(\n+ \"initial_strict,transform_strict,expected_strict\",\n+ (\n+ (False, None, False),\n+ (True, None, True),\n+ (False, True, True),\n+ (True, False, False),\n+ ),\n+)\n+def test_transform_strict(fresh_db, initial_strict, transform_strict, expected_strict):\n+ if not fresh_db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ dogs = fresh_db.table(\"dogs\", strict=initial_strict)\n dogs.insert({\"id\": 1, \"name\": \"Cleo\"})\n- assert dogs.strict == strict or not fresh_db.supports_strict\n- dogs.transform(not_null={\"name\"})\n- assert dogs.strict == strict or not fresh_db.supports_strict\n+ assert dogs.strict is initial_strict\n+ dogs.transform(strict=transform_strict)\n+ assert dogs.strict is expected_strict\n+\n+\n+def test_transform_to_strict_with_invalid_data(fresh_db):\n+ if not fresh_db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ dogs = fresh_db[\"dogs\"]\n+ dogs.create({\"id\": int})\n+ dogs.insert({\"id\": \"not-an-integer\"})\n+\n+ with pytest.raises(sqlite3.IntegrityError):\n+ dogs.transform(strict=True)\n+\n+ assert dogs.strict is False\n+ assert list(dogs.rows) == [{\"id\": \"not-an-integer\"}]\n+ assert fresh_db.table_names() == [\"dogs\"]\n+\n+\n+def test_transform_strict_updates_default(fresh_db):\n+ if not fresh_db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ table = fresh_db.table(\"items\", strict=True)\n+ table.create({\"id\": int})\n+\n+ table.transform(strict=False)\n+ assert table.strict is False\n+\n+ table.create({\"id\": int}, replace=True)\n+ assert table.strict is False\n+\n+\n+@pytest.mark.parametrize(\"method_name\", (\"transform\", \"transform_sql\"))\n+def test_transform_to_strict_not_supported(fresh_db, method_name):\n+ table = fresh_db[\"items\"]\n+ table.create({\"id\": int})\n+ fresh_db._supports_strict = False\n+\n+ with pytest.raises(TransformError, match=\"SQLite does not support STRICT tables\"):\n+ getattr(table, method_name)(strict=True)\n+\n+ assert table.strict is False\n \n \n @pytest.mark.parametrize(",
"comments": [
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948473657",
"body": "> use `uv run python -c` and manually exercise the new `.transform(strict=`) option, see if you can find any edge-cases or bugs\r\n\r\nGPT-5.6 Sol then found some edge-cases:\r\n\r\n> Manual probing found two bugs directly relevant to the new option.\r\n> \r\n> 1. Explicit `strict=True` silently fails on unsupported SQLite\r\n> \r\n> Simulating an older SQLite version produced:\r\n> \r\n> ```python\r\n> t.transform(strict=True)\r\n> t.strict # False\r\n> ```\r\n> \r\n> `create_table_sql()` only appends `STRICT` when `db.supports_strict` is true ([db.py](/Users/simon/Dropbox/dev/sqlite-utils/sqlite_utils/db.py:1554)). An explicit conversion should probably raise an informative error instead of succeeding as a no-op.\r\n> \r\n> 2. The `Table` object retains stale strict defaults\r\n> \r\n> After:\r\n> \r\n> ```python\r\n> t = db.table(\"items\", strict=True)\r\n> t.create({\"id\": int})\r\n> t.transform(strict=False)\r\n> ```\r\n> \r\n> The schema becomes non-strict, but `t._defaults[\"strict\"]` remains `True`. Dropping and recreating through the same object unexpectedly creates another strict table. The reverse happens when converting from non-strict to strict.\r\n> \r\n> The default should be updated after a successful transformation, including storing `False`. It should remain unchanged if the transformation rolls back.\r\n> \r\n> Other notable edge cases:\r\n> \r\n> - SQLite `ANY` columns are introspected as `float`, so transforming a native strict table containing `ANY` can fail or change it to `REAL`. This is an existing transform/introspection limitation, but applies to `strict=None` and `strict=True`.\r\n> - An incompatible declared default does not prevent conversion. An empty `INTEGER DEFAULT 'not-an-integer'` table becomes strict successfully, but later inserting the default raises `IntegrityError`. This is SQLite behavior.\r\n> - Triggers are dropped during transformation. This is existing documented behavior, not introduced by this change.\r\n> - Losslessly convertible values work: `\"123\"` and `\"123.0\"` become integer `123`.\r\n> - Fractional numbers, incompatible text, and BLOBs correctly fail and roll back.\r\n> - Indexes and foreign keys were preserved.\r\n> - Failed conversion inside an existing `db.atomic()` transaction rolled back only the transform; the outer transaction remained usable.\r\n> - `keep_table` correctly retained the original non-strict table alongside the new strict table.\r\n> - Repeated `None → True → None → False → None` transitions behaved correctly.\r\n> \r\n> No files were changed during this diagnostic pass.",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948473657,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:30:03Z",
"updated_at": "2026-07-11T19:30:03Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<blockquote>\n<p dir=\"auto\">use <code class=\"notranslate\">uv run python -c</code> and manually exercise the new <code class=\"notranslate\">.transform(strict=</code>) option, see if you can find any edge-cases or bugs</p>\n</blockquote>\n<p dir=\"auto\">GPT-5.6 Sol then found some edge-cases:</p>\n<blockquote>\n<p dir=\"auto\">Manual probing found two bugs directly relevant to the new option.</p>\n<ol dir=\"auto\">\n<li>Explicit <code class=\"notranslate\">strict=True</code> silently fails on unsupported SQLite</li>\n</ol>\n<p dir=\"auto\">Simulating an older SQLite version produced:</p>\n<div class=\"highlight highlight-source-python notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"t.transform(strict=True)\nt.strict # False\"><pre class=\"notranslate\"><span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">transform</span>(<span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n<span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">strict</span> <span class=\"pl-c\"># False</span></pre></div>\n<p dir=\"auto\"><code class=\"notranslate\">create_table_sql()</code> only appends <code class=\"notranslate\">STRICT</code> when <code class=\"notranslate\">db.supports_strict</code> is true (<a href=\"/Users/simon/Dropbox/dev/sqlite-utils/sqlite_utils/db.py:1554\">db.py</a>). An explicit conversion should probably raise an informative error instead of succeeding as a no-op.</p>\n<ol start=\"2\" dir=\"auto\">\n<li>The <code class=\"notranslate\">Table</code> object retains stale strict defaults</li>\n</ol>\n<p dir=\"auto\">After:</p>\n<div class=\"highlight highlight-source-python notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"t = db.table("items", strict=True)\nt.create({"id": int})\nt.transform(strict=False)\"><pre class=\"notranslate\"><span class=\"pl-s1\">t</span> <span class=\"pl-c1\">=</span> <span class=\"pl-s1\">db</span>.<span class=\"pl-c1\">table</span>(<span class=\"pl-s\">\"items\"</span>, <span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n<span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">create</span>({<span class=\"pl-s\">\"id\"</span>: <span class=\"pl-s1\">int</span>})\n<span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">transform</span>(<span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">False</span>)</pre></div>\n<p dir=\"auto\">The schema becomes non-strict, but <code class=\"notranslate\">t._defaults[\"strict\"]</code> remains <code class=\"notranslate\">True</code>. Dropping and recreating through the same object unexpectedly creates another strict table. The reverse happens when converting from non-strict to strict.</p>\n<p dir=\"auto\">The default should be updated after a successful transformation, including storing <code class=\"notranslate\">False</code>. It should remain unchanged if the transformation rolls back.</p>\n<p dir=\"auto\">Other notable edge cases:</p>\n<ul dir=\"auto\">\n<li>SQLite <code class=\"notranslate\">ANY</code> columns are introspected as <code class=\"notranslate\">float</code>, so transforming a native strict table containing <code class=\"notranslate\">ANY</code> can fail or change it to <code class=\"notranslate\">REAL</code>. This is an existing transform/introspection limitation, but applies to <code class=\"notranslate\">strict=None</code> and <code class=\"notranslate\">strict=True</code>.</li>\n<li>An incompatible declared default does not prevent conversion. An empty <code class=\"notranslate\">INTEGER DEFAULT 'not-an-integer'</code> table becomes strict successfully, but later inserting the default raises <code class=\"notranslate\">IntegrityError</code>. This is SQLite behavior.</li>\n<li>Triggers are dropped during transformation. This is existing documented behavior, not introduced by this change.</li>\n<li>Losslessly convertible values work: <code class=\"notranslate\">\"123\"</code> and <code class=\"notranslate\">\"123.0\"</code> become integer <code class=\"notranslate\">123</code>.</li>\n<li>Fractional numbers, incompatible text, and BLOBs correctly fail and roll back.</li>\n<li>Indexes and foreign keys were preserved.</li>\n<li>Failed conversion inside an existing <code class=\"notranslate\">db.atomic()</code> transaction rolled back only the transform; the outer transaction remained usable.</li>\n<li><code class=\"notranslate\">keep_table</code> correctly retained the original non-strict table alongside the new strict table.</li>\n<li>Repeated <code class=\"notranslate\">None → True → None → False → None</code> transitions behaved correctly.</li>\n</ul>\n<p dir=\"auto\">No files were changed during this diagnostic pass.</p>\n</blockquote>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948475411",
"body": "## [Codecov](https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison) Report\n:white_check_mark: All modified and coverable lines are covered by tests.\n:white_check_mark: Project coverage is 95.54%. Comparing base ([`6531a57`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/6531a57863ce23d502e504fd8fcd375fbe5cbb7f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)) to head ([`5b9898e`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/5b9898ed9b5f71d45d85001543363cd48eadd646?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)).\n\n<details><summary>Additional details and impacted files</summary>\n\n\n\n```diff\n@@ Coverage Diff @@\n## main #788 +/- ##\n=======================================\n Coverage 95.54% 95.54% \n=======================================\n Files 9 9 \n Lines 3793 3796 +3 \n=======================================\n+ Hits 3624 3627 +3 \n Misses 169 169 \n```\n</details>\n\n[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison). \n:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison).\n<details><summary> :rocket: New features to boost your workflow: </summary>\n\n- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.\n</details>",
"user": {
"login": "codecov[bot]",
"name": "codecov[bot]",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/in/254?v=4",
"id": 22429695
},
"id": 4948475411,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:30:37Z",
"updated_at": "2026-07-11T23:33:48Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<h2 dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Codecov</a> Report</h2>\n<p dir=\"auto\">✅ All modified and coverable lines are covered by tests.<br>\n✅ Project coverage is 95.54%. Comparing base (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/6531a57863ce23d502e504fd8fcd375fbe5cbb7f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">6531a57</code></a>) to head (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/5b9898ed9b5f71d45d85001543363cd48eadd646?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">5b9898e</code></a>).</p>\n<details><summary>Additional details and impacted files</summary>\n<div class=\"highlight highlight-source-diff notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"@@ Coverage Diff @@\n## main #788 +/- ##\n=======================================\n Coverage 95.54% 95.54% \n=======================================\n Files 9 9 \n Lines 3793 3796 +3 \n=======================================\n+ Hits 3624 3627 +3 \n Misses 169 169 \"><pre class=\"notranslate\"><span class=\"pl-mdr\">@@ Coverage Diff @@</span>\n<span class=\"pl-c\"><span class=\"pl-c\">#</span># main #788 +/- ##</span>\n=======================================\n Coverage 95.54% 95.54% \n=======================================\n Files 9 9 \n Lines 3793 3796 +3 \n=======================================\n<span class=\"pl-mi1\"><span class=\"pl-mi1\">+</span> Hits 3624 3627 +3 </span>\n Misses 169 169 </pre></div>\n</details>\n<p dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">☔ View full report in Codecov by Harness</a>.<br>\n📢 Have feedback on the report? <a href=\"https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Share it here</a>.</p>\n<details><summary> 🚀 New features to boost your workflow: </summary>\n<ul dir=\"auto\">\n<li>❄️ <a href=\"https://docs.codecov.com/docs/test-analytics\" rel=\"nofollow\">Test Analytics</a>: Detect flaky tests, report on failures, and find test suite problems.</li>\n</ul>\n</details>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948479356",
"body": " I'm going to raise errors if you attempt to convert to STRICT with a SQLite version that fails the `db.supports_strict` test.",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948479356,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:31:51Z",
"updated_at": "2026-07-11T19:31:51Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">I'm going to raise errors if you attempt to convert to STRICT with a SQLite version that fails the <code class=\"notranslate\">db.supports_strict</code> test.</p>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948493701",
"body": "We don't have any mechanism to support `ANY` columns at the moment. Open question how to deal with that. Options include:\r\n\r\n- Ignore the problem entirely\r\n- Add a `sqlite_utils.ANY` constant which can be used in create table calls, e.g. `db.create_table(\"t\", {\"id\": int, \"name\": str, \"misc\": sqlite_utils.ANY})` - would have to be handled in `add_column()` and `transform()` and a bunch of other places too.\r\n- Don't support them in create_table/etc but DO support them in introspection, since that's part of how `transform()` works",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948493701,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:36:01Z",
"updated_at": "2026-07-11T19:36:01Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">We don't have any mechanism to support <code class=\"notranslate\">ANY</code> columns at the moment. Open question how to deal with that. Options include:</p>\n<ul dir=\"auto\">\n<li>Ignore the problem entirely</li>\n<li>Add a <code class=\"notranslate\">sqlite_utils.ANY</code> constant which can be used in create table calls, e.g. <code class=\"notranslate\">db.create_table(\"t\", {\"id\": int, \"name\": str, \"misc\": sqlite_utils.ANY})</code> - would have to be handled in <code class=\"notranslate\">add_column()</code> and <code class=\"notranslate\">transform()</code> and a bunch of other places too.</li>\n<li>Don't support them in create_table/etc but DO support them in introspection, since that's part of how <code class=\"notranslate\">transform()</code> works</li>\n</ul>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948505824",
"body": "Here's a reproduction of the problem where the default `strict` value for a table is not correctly updated:\r\n```python\r\nfrom sqlite_utils import Database\r\n\r\ndb = Database(memory=True)\r\n\r\ntable = db.table(\"items\", strict=True)\r\ntable.create({\"id\": int})\r\n\r\ntable.transform(strict=False)\r\nassert table.strict is False\r\n\r\n# Recreate using the same Table object's stale strict=True default:\r\ntable.create({\"id\": int}, replace=True)\r\n\r\nassert table.strict is True # Unexpectedly strict again\r\n```",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948505824,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:40:09Z",
"updated_at": "2026-07-11T19:40:09Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">Here's a reproduction of the problem where the default <code class=\"notranslate\">strict</code> value for a table is not correctly updated:</p>\n<div class=\"highlight highlight-source-python notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"from sqlite_utils import Database\n\ndb = Database(memory=True)\n\ntable = db.table("items", strict=True)\ntable.create({"id": int})\n\ntable.transform(strict=False)\nassert table.strict is False\n\n# Recreate using the same Table object's stale strict=True default:\ntable.create({"id": int}, replace=True)\n\nassert table.strict is True # Unexpectedly strict again\"><pre class=\"notranslate\"><span class=\"pl-k\">from</span> <span class=\"pl-s1\">sqlite_utils</span> <span class=\"pl-k\">import</span> <span class=\"pl-v\">Database</span>\n\n<span class=\"pl-s1\">db</span> <span class=\"pl-c1\">=</span> <span class=\"pl-en\">Database</span>(<span class=\"pl-s1\">memory</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n\n<span class=\"pl-s1\">table</span> <span class=\"pl-c1\">=</span> <span class=\"pl-s1\">db</span>.<span class=\"pl-c1\">table</span>(<span class=\"pl-s\">\"items\"</span>, <span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n<span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">create</span>({<span class=\"pl-s\">\"id\"</span>: <span class=\"pl-s1\">int</span>})\n\n<span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">transform</span>(<span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">False</span>)\n<span class=\"pl-k\">assert</span> <span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">strict</span> <span class=\"pl-c1\">is</span> <span class=\"pl-c1\">False</span>\n\n<span class=\"pl-c\"># Recreate using the same Table object's stale strict=True default:</span>\n<span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">create</span>({<span class=\"pl-s\">\"id\"</span>: <span class=\"pl-s1\">int</span>}, <span class=\"pl-s1\">replace</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n\n<span class=\"pl-k\">assert</span> <span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">strict</span> <span class=\"pl-c1\">is</span> <span class=\"pl-c1\">True</span> <span class=\"pl-c\"># Unexpectedly strict again</span></pre></div>"
}
],
"created_at": "2026-07-11T19:29:17Z",
"updated_at": "2026-07-11T23:37:11Z",
"closed_at": null,
"merged_at": null,
"commits": 5,
"changed_files": 8,
"additions": 171,
"deletions": 8,
"display_url": "https://github.com/simonw/sqlite-utils/pull/788",
"display_title": ".transform(strict=) and sqlite-utils transform --strict/--no-strict"
},
"url": "https://github.com/simonw/sqlite-utils/pull/788",
"title": ".transform(strict=) and sqlite-utils transform --strict/--no-strict",
"diff": "@@ -9,6 +9,8 @@\n Unreleased\n ----------\n \n+- ``table.transform()`` and ``table.transform_sql()`` now accept ``strict=True`` or ``strict=False`` to change a table's SQLite strict mode. Omitting the option, or passing ``strict=None``, preserves the existing mode. (:issue:`787`)\n+- The ``sqlite-utils transform`` command now accepts ``--strict`` and ``--no-strict`` to change a table's SQLite strict mode. Omitting both options preserves the existing mode. (:issue:`787`)\n - ``sqlite-utils query`` can now read the SQL query from standard input by passing ``-`` in place of the query, for example ``echo \"select * from dogs\" | sqlite-utils query dogs.db -``. (:issue:`765`)\n - ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for :ref:`providing a block of Python code <cli_insert_code>` (or a path to a ``.py`` file) that defines a ``rows()`` function or ``rows`` iterable of rows to insert, as an alternative to importing from a file. (:issue:`684`)\n - ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept ``--type column-name type`` to :ref:`override the type automatically chosen when the table is created <cli_insert_csv_tsv_column_types>`. This is useful for CSV or TSV columns such as ZIP codes that look like integers but should be stored as ``TEXT`` to preserve leading zeros. (:issue:`131`)\n@@ -508,6 +508,8 @@ See :ref:`cli_transform_table`.\n Add a foreign key constraint from a column to\n another table with another column\n --drop-foreign-key TEXT Drop foreign key constraint for this column\n+ --strict / --no-strict Enable or disable STRICT mode (default:\n+ preserve current mode)\n --sql Output SQL without executing it\n --load-extension TEXT Path to SQLite extension, with optional\n :entrypoint\n@@ -2182,7 +2182,7 @@ Use ``--ignore`` to ignore the error if the table does not exist.\n Transforming tables\n ===================\n \n-The ``transform`` command allows you to apply complex transformations to a table that cannot be implemented using a regular SQLite ``ALTER TABLE`` command. See :ref:`python_api_transform` for details of how this works. The ``transform`` command preserves a table's ``STRICT`` mode.\n+The ``transform`` command allows you to apply complex transformations to a table that cannot be implemented using a regular SQLite ``ALTER TABLE`` command. See :ref:`python_api_transform` for details of how this works. By default, the ``transform`` command preserves a table's ``STRICT`` mode.\n \n .. code-block:: bash\n \n@@ -2228,6 +2228,12 @@ Every option for this table (with the exception of ``--pk-none``) can be specifi\n ``--add-foreign-key column other_table other_column``\n Add a foreign key constraint to ``column`` pointing to ``other_table.other_column``.\n \n+``--strict``\n+ Convert the table to a `SQLite STRICT table <https://www.sqlite.org/stricttables.html>`__. The command fails if the available SQLite version does not support strict tables. If existing rows contain values that are incompatible with their declared column types the transformation fails and the original table is left unchanged.\n+\n+``--no-strict``\n+ Convert a strict table back to a regular non-strict table.\n+\n If you want to see the SQL that will be executed to make the change without actually executing it, add the ``--sql`` flag. For example:\n \n .. code-block:: bash\n@@ -1753,6 +1753,29 @@ To alter the type of a column, use the ``types=`` argument:\n \n See :ref:`python_api_add_column` for a list of available types.\n \n+.. _python_api_transform_strict:\n+\n+Changing strict mode\n+--------------------\n+\n+The optional ``strict=`` parameter can change whether a table uses `SQLite STRICT mode <https://www.sqlite.org/stricttables.html>`__. Pass ``strict=True`` to convert a regular table to a strict table:\n+\n+.. code-block:: python\n+\n+ table.transform(strict=True)\n+\n+Pass ``strict=False`` to convert a strict table back to a regular non-strict table:\n+\n+.. code-block:: python\n+\n+ table.transform(strict=False)\n+\n+The default is ``strict=None``, which preserves the table's existing strict mode.\n+\n+Passing ``strict=True`` raises ``sqlite_utils.db.TransformError`` if the available SQLite version does not support strict tables.\n+\n+Converting to a strict table validates all existing rows as they are copied into the replacement table. If a value is incompatible with its declared column type, SQLite raises ``sqlite3.IntegrityError`` and the transformation is rolled back, leaving the original table and its data unchanged.\n+\n .. _python_api_transform_rename_columns:\n \n Renaming columns\n@@ -2718,6 +2718,11 @@ def schema(\n multiple=True,\n help=\"Drop foreign key constraint for this column\",\n )\n+@click.option(\n+ \"--strict/--no-strict\",\n+ default=None,\n+ help=\"Enable or disable STRICT mode (default: preserve current mode)\",\n+)\n @click.option(\"--sql\", is_flag=True, help=\"Output SQL without executing it\")\n @load_extension_option\n def transform(\n@@ -2735,6 +2740,7 @@ def transform(\n default_none,\n add_foreign_keys,\n drop_foreign_keys,\n+ strict,\n sql,\n load_extension,\n ):\n@@ -2796,6 +2802,7 @@ def transform(\n defaults=default_dict,\n drop_foreign_keys=drop_foreign_keys_value,\n add_foreign_keys=add_foreign_keys_value,\n+ strict=strict,\n ):\n click.echo(line)\n else:\n@@ -2809,6 +2816,7 @@ def transform(\n defaults=default_dict,\n drop_foreign_keys=drop_foreign_keys_value,\n add_foreign_keys=add_foreign_keys_value,\n+ strict=strict,\n )\n \n \n@@ -2514,6 +2514,7 @@ def transform(\n foreign_keys: Optional[ForeignKeysType] = None,\n column_order: Optional[List[str]] = None,\n keep_table: Optional[str] = None,\n+ strict: Optional[bool] = None,\n ) -> \"Table\":\n \"\"\"\n Apply an advanced alter table, including operations that are not supported by\n@@ -2536,6 +2537,8 @@ def transform(\n to use when creating the table\n :param keep_table: If specified, the existing table will be renamed to this and will not be\n dropped\n+ :param strict: Set to ``True`` to make the table strict or ``False`` to make it\n+ non-strict. Defaults to ``None``, which preserves the existing strict mode.\n \"\"\"\n if not self.exists():\n raise ValueError(\"Cannot transform a table that doesn't exist yet\")\n@@ -2551,6 +2554,7 @@ def transform(\n foreign_keys=foreign_keys,\n column_order=column_order,\n keep_table=keep_table,\n+ strict=strict,\n )\n pragma_foreign_keys_was_on = bool(\n self.db.execute(\"PRAGMA foreign_keys\").fetchone()[0]\n@@ -2587,6 +2591,8 @@ def transform(\n self.db.execute(\"PRAGMA defer_foreign_keys=OFF;\")\n if should_disable_foreign_keys:\n self.db.execute(\"PRAGMA foreign_keys=1;\")\n+ if strict is not None:\n+ self._defaults[\"strict\"] = strict\n return self\n \n def transform_sql(\n@@ -2604,6 +2610,7 @@ def transform_sql(\n column_order: Optional[List[str]] = None,\n tmp_suffix: Optional[str] = None,\n keep_table: Optional[str] = None,\n+ strict: Optional[bool] = None,\n ) -> List[str]:\n \"\"\"\n Return a list of SQL statements that should be executed in order to apply this transformation.\n@@ -2624,7 +2631,11 @@ def transform_sql(\n :param tmp_suffix: Suffix to use for the temporary table name\n :param keep_table: If specified, the existing table will be renamed to this and will not be\n dropped\n+ :param strict: Set to ``True`` to make the table strict or ``False`` to make it\n+ non-strict. Defaults to ``None``, which preserves the existing strict mode.\n \"\"\"\n+ if strict is True and not self.db.supports_strict:\n+ raise TransformError(\"SQLite does not support STRICT tables\")\n types = types or {}\n rename = rename or {}\n drop = drop or set()\n@@ -2806,7 +2817,7 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n defaults=create_table_defaults,\n foreign_keys=create_table_foreign_keys,\n column_order=column_order,\n- strict=self.strict,\n+ strict=self.strict if strict is None else strict,\n ).strip()\n )\n \n@@ -3,6 +3,7 @@\n from click.testing import CliRunner\n from pathlib import Path\n import subprocess\n+import sqlite3\n import sys\n import json\n import os\n@@ -1939,6 +1940,64 @@ def test_transform_sql(db_path):\n assert db[\"dogs\"].schema == original_schema\n \n \n+@pytest.mark.parametrize(\n+ \"initial_strict,args,expected_strict\",\n+ (\n+ (False, [], False),\n+ (True, [], True),\n+ (False, [\"--strict\"], True),\n+ (True, [\"--no-strict\"], False),\n+ ),\n+)\n+def test_transform_strict_option(db_path, initial_strict, args, expected_strict):\n+ db = Database(db_path)\n+ if not db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ db[\"dogs\"].create({\"id\": int}, strict=initial_strict)\n+\n+ result = CliRunner().invoke(cli.cli, [\"transform\", db_path, \"dogs\"] + args)\n+\n+ assert result.exit_code == 0, result.output\n+ assert db[\"dogs\"].strict is expected_strict\n+\n+\n+@pytest.mark.parametrize(\n+ \"initial_strict,flag,sql_is_strict\",\n+ (\n+ (False, \"--strict\", True),\n+ (True, \"--no-strict\", False),\n+ ),\n+)\n+def test_transform_strict_option_sql(db_path, initial_strict, flag, sql_is_strict):\n+ db = Database(db_path)\n+ if not db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ db[\"dogs\"].create({\"id\": int}, strict=initial_strict)\n+\n+ result = CliRunner().invoke(cli.cli, [\"transform\", db_path, \"dogs\", flag, \"--sql\"])\n+\n+ assert result.exit_code == 0, result.output\n+ assert (\") STRICT;\" in result.output) is sql_is_strict\n+ assert db[\"dogs\"].strict is initial_strict\n+\n+\n+def test_transform_strict_option_with_invalid_data(db_path):\n+ db = Database(db_path)\n+ if not db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ dogs = db[\"dogs\"]\n+ dogs.create({\"id\": int})\n+ dogs.insert({\"id\": \"not-an-integer\"})\n+\n+ result = CliRunner().invoke(cli.cli, [\"transform\", db_path, \"dogs\", \"--strict\"])\n+\n+ assert result.exit_code == 1\n+ assert isinstance(result.exception, sqlite3.IntegrityError)\n+ assert dogs.strict is False\n+ assert list(dogs.rows) == [{\"id\": \"not-an-integer\"}]\n+ assert not any(name.startswith(\"dogs_new_\") for name in db.table_names())\n+\n+\n @pytest.mark.parametrize(\n \"extra_args,expected_schema\",\n (\n@@ -1,3 +1,5 @@\n+import sqlite3\n+\n from sqlite_utils.db import ForeignKey, TransformError\n from sqlite_utils.utils import OperationalError\n import pytest\n@@ -566,13 +568,63 @@ def test_transform_preserves_rowids(fresh_db, table_type):\n assert previous_rows == next_rows\n \n \n-@pytest.mark.parametrize(\"strict\", (False, True))\n-def test_transform_strict(fresh_db, strict):\n- dogs = fresh_db.table(\"dogs\", strict=strict)\n+@pytest.mark.parametrize(\n+ \"initial_strict,transform_strict,expected_strict\",\n+ (\n+ (False, None, False),\n+ (True, None, True),\n+ (False, True, True),\n+ (True, False, False),\n+ ),\n+)\n+def test_transform_strict(fresh_db, initial_strict, transform_strict, expected_strict):\n+ if not fresh_db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ dogs = fresh_db.table(\"dogs\", strict=initial_strict)\n dogs.insert({\"id\": 1, \"name\": \"Cleo\"})\n- assert dogs.strict == strict or not fresh_db.supports_strict\n- dogs.transform(not_null={\"name\"})\n- assert dogs.strict == strict or not fresh_db.supports_strict\n+ assert dogs.strict is initial_strict\n+ dogs.transform(strict=transform_strict)\n+ assert dogs.strict is expected_strict\n+\n+\n+def test_transform_to_strict_with_invalid_data(fresh_db):\n+ if not fresh_db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ dogs = fresh_db[\"dogs\"]\n+ dogs.create({\"id\": int})\n+ dogs.insert({\"id\": \"not-an-integer\"})\n+\n+ with pytest.raises(sqlite3.IntegrityError):\n+ dogs.transform(strict=True)\n+\n+ assert dogs.strict is False\n+ assert list(dogs.rows) == [{\"id\": \"not-an-integer\"}]\n+ assert fresh_db.table_names() == [\"dogs\"]\n+\n+\n+def test_transform_strict_updates_default(fresh_db):\n+ if not fresh_db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ table = fresh_db.table(\"items\", strict=True)\n+ table.create({\"id\": int})\n+\n+ table.transform(strict=False)\n+ assert table.strict is False\n+\n+ table.create({\"id\": int}, replace=True)\n+ assert table.strict is False\n+\n+\n+@pytest.mark.parametrize(\"method_name\", (\"transform\", \"transform_sql\"))\n+def test_transform_to_strict_not_supported(fresh_db, method_name):\n+ table = fresh_db[\"items\"]\n+ table.create({\"id\": int})\n+ fresh_db._supports_strict = False\n+\n+ with pytest.raises(TransformError, match=\"SQLite does not support STRICT tables\"):\n+ getattr(table, method_name)(strict=True)\n+\n+ assert table.strict is False\n \n \n @pytest.mark.parametrize(",
"comments": [
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948473657",
"body": "> use `uv run python -c` and manually exercise the new `.transform(strict=`) option, see if you can find any edge-cases or bugs\r\n\r\nGPT-5.6 Sol then found some edge-cases:\r\n\r\n> Manual probing found two bugs directly relevant to the new option.\r\n> \r\n> 1. Explicit `strict=True` silently fails on unsupported SQLite\r\n> \r\n> Simulating an older SQLite version produced:\r\n> \r\n> ```python\r\n> t.transform(strict=True)\r\n> t.strict # False\r\n> ```\r\n> \r\n> `create_table_sql()` only appends `STRICT` when `db.supports_strict` is true ([db.py](/Users/simon/Dropbox/dev/sqlite-utils/sqlite_utils/db.py:1554)). An explicit conversion should probably raise an informative error instead of succeeding as a no-op.\r\n> \r\n> 2. The `Table` object retains stale strict defaults\r\n> \r\n> After:\r\n> \r\n> ```python\r\n> t = db.table(\"items\", strict=True)\r\n> t.create({\"id\": int})\r\n> t.transform(strict=False)\r\n> ```\r\n> \r\n> The schema becomes non-strict, but `t._defaults[\"strict\"]` remains `True`. Dropping and recreating through the same object unexpectedly creates another strict table. The reverse happens when converting from non-strict to strict.\r\n> \r\n> The default should be updated after a successful transformation, including storing `False`. It should remain unchanged if the transformation rolls back.\r\n> \r\n> Other notable edge cases:\r\n> \r\n> - SQLite `ANY` columns are introspected as `float`, so transforming a native strict table containing `ANY` can fail or change it to `REAL`. This is an existing transform/introspection limitation, but applies to `strict=None` and `strict=True`.\r\n> - An incompatible declared default does not prevent conversion. An empty `INTEGER DEFAULT 'not-an-integer'` table becomes strict successfully, but later inserting the default raises `IntegrityError`. This is SQLite behavior.\r\n> - Triggers are dropped during transformation. This is existing documented behavior, not introduced by this change.\r\n> - Losslessly convertible values work: `\"123\"` and `\"123.0\"` become integer `123`.\r\n> - Fractional numbers, incompatible text, and BLOBs correctly fail and roll back.\r\n> - Indexes and foreign keys were preserved.\r\n> - Failed conversion inside an existing `db.atomic()` transaction rolled back only the transform; the outer transaction remained usable.\r\n> - `keep_table` correctly retained the original non-strict table alongside the new strict table.\r\n> - Repeated `None → True → None → False → None` transitions behaved correctly.\r\n> \r\n> No files were changed during this diagnostic pass.",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948473657,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:30:03Z",
"updated_at": "2026-07-11T19:30:03Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<blockquote>\n<p dir=\"auto\">use <code class=\"notranslate\">uv run python -c</code> and manually exercise the new <code class=\"notranslate\">.transform(strict=</code>) option, see if you can find any edge-cases or bugs</p>\n</blockquote>\n<p dir=\"auto\">GPT-5.6 Sol then found some edge-cases:</p>\n<blockquote>\n<p dir=\"auto\">Manual probing found two bugs directly relevant to the new option.</p>\n<ol dir=\"auto\">\n<li>Explicit <code class=\"notranslate\">strict=True</code> silently fails on unsupported SQLite</li>\n</ol>\n<p dir=\"auto\">Simulating an older SQLite version produced:</p>\n<div class=\"highlight highlight-source-python notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"t.transform(strict=True)\nt.strict # False\"><pre class=\"notranslate\"><span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">transform</span>(<span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n<span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">strict</span> <span class=\"pl-c\"># False</span></pre></div>\n<p dir=\"auto\"><code class=\"notranslate\">create_table_sql()</code> only appends <code class=\"notranslate\">STRICT</code> when <code class=\"notranslate\">db.supports_strict</code> is true (<a href=\"/Users/simon/Dropbox/dev/sqlite-utils/sqlite_utils/db.py:1554\">db.py</a>). An explicit conversion should probably raise an informative error instead of succeeding as a no-op.</p>\n<ol start=\"2\" dir=\"auto\">\n<li>The <code class=\"notranslate\">Table</code> object retains stale strict defaults</li>\n</ol>\n<p dir=\"auto\">After:</p>\n<div class=\"highlight highlight-source-python notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"t = db.table("items", strict=True)\nt.create({"id": int})\nt.transform(strict=False)\"><pre class=\"notranslate\"><span class=\"pl-s1\">t</span> <span class=\"pl-c1\">=</span> <span class=\"pl-s1\">db</span>.<span class=\"pl-c1\">table</span>(<span class=\"pl-s\">\"items\"</span>, <span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n<span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">create</span>({<span class=\"pl-s\">\"id\"</span>: <span class=\"pl-s1\">int</span>})\n<span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">transform</span>(<span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">False</span>)</pre></div>\n<p dir=\"auto\">The schema becomes non-strict, but <code class=\"notranslate\">t._defaults[\"strict\"]</code> remains <code class=\"notranslate\">True</code>. Dropping and recreating through the same object unexpectedly creates another strict table. The reverse happens when converting from non-strict to strict.</p>\n<p dir=\"auto\">The default should be updated after a successful transformation, including storing <code class=\"notranslate\">False</code>. It should remain unchanged if the transformation rolls back.</p>\n<p dir=\"auto\">Other notable edge cases:</p>\n<ul dir=\"auto\">\n<li>SQLite <code class=\"notranslate\">ANY</code> columns are introspected as <code class=\"notranslate\">float</code>, so transforming a native strict table containing <code class=\"notranslate\">ANY</code> can fail or change it to <code class=\"notranslate\">REAL</code>. This is an existing transform/introspection limitation, but applies to <code class=\"notranslate\">strict=None</code> and <code class=\"notranslate\">strict=True</code>.</li>\n<li>An incompatible declared default does not prevent conversion. An empty <code class=\"notranslate\">INTEGER DEFAULT 'not-an-integer'</code> table becomes strict successfully, but later inserting the default raises <code class=\"notranslate\">IntegrityError</code>. This is SQLite behavior.</li>\n<li>Triggers are dropped during transformation. This is existing documented behavior, not introduced by this change.</li>\n<li>Losslessly convertible values work: <code class=\"notranslate\">\"123\"</code> and <code class=\"notranslate\">\"123.0\"</code> become integer <code class=\"notranslate\">123</code>.</li>\n<li>Fractional numbers, incompatible text, and BLOBs correctly fail and roll back.</li>\n<li>Indexes and foreign keys were preserved.</li>\n<li>Failed conversion inside an existing <code class=\"notranslate\">db.atomic()</code> transaction rolled back only the transform; the outer transaction remained usable.</li>\n<li><code class=\"notranslate\">keep_table</code> correctly retained the original non-strict table alongside the new strict table.</li>\n<li>Repeated <code class=\"notranslate\">None → True → None → False → None</code> transitions behaved correctly.</li>\n</ul>\n<p dir=\"auto\">No files were changed during this diagnostic pass.</p>\n</blockquote>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948475411",
"body": "## [Codecov](https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison) Report\n:white_check_mark: All modified and coverable lines are covered by tests.\n:white_check_mark: Project coverage is 95.54%. Comparing base ([`6531a57`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/6531a57863ce23d502e504fd8fcd375fbe5cbb7f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)) to head ([`5b9898e`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/5b9898ed9b5f71d45d85001543363cd48eadd646?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)).\n\n<details><summary>Additional details and impacted files</summary>\n\n\n\n```diff\n@@ Coverage Diff @@\n## main #788 +/- ##\n=======================================\n Coverage 95.54% 95.54% \n=======================================\n Files 9 9 \n Lines 3793 3796 +3 \n=======================================\n+ Hits 3624 3627 +3 \n Misses 169 169 \n```\n</details>\n\n[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison). \n:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison).\n<details><summary> :rocket: New features to boost your workflow: </summary>\n\n- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.\n</details>",
"user": {
"login": "codecov[bot]",
"name": "codecov[bot]",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/in/254?v=4",
"id": 22429695
},
"id": 4948475411,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:30:37Z",
"updated_at": "2026-07-11T23:33:48Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<h2 dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Codecov</a> Report</h2>\n<p dir=\"auto\">✅ All modified and coverable lines are covered by tests.<br>\n✅ Project coverage is 95.54%. Comparing base (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/6531a57863ce23d502e504fd8fcd375fbe5cbb7f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">6531a57</code></a>) to head (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/5b9898ed9b5f71d45d85001543363cd48eadd646?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">5b9898e</code></a>).</p>\n<details><summary>Additional details and impacted files</summary>\n<div class=\"highlight highlight-source-diff notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"@@ Coverage Diff @@\n## main #788 +/- ##\n=======================================\n Coverage 95.54% 95.54% \n=======================================\n Files 9 9 \n Lines 3793 3796 +3 \n=======================================\n+ Hits 3624 3627 +3 \n Misses 169 169 \"><pre class=\"notranslate\"><span class=\"pl-mdr\">@@ Coverage Diff @@</span>\n<span class=\"pl-c\"><span class=\"pl-c\">#</span># main #788 +/- ##</span>\n=======================================\n Coverage 95.54% 95.54% \n=======================================\n Files 9 9 \n Lines 3793 3796 +3 \n=======================================\n<span class=\"pl-mi1\"><span class=\"pl-mi1\">+</span> Hits 3624 3627 +3 </span>\n Misses 169 169 </pre></div>\n</details>\n<p dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">☔ View full report in Codecov by Harness</a>.<br>\n📢 Have feedback on the report? <a href=\"https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Share it here</a>.</p>\n<details><summary> 🚀 New features to boost your workflow: </summary>\n<ul dir=\"auto\">\n<li>❄️ <a href=\"https://docs.codecov.com/docs/test-analytics\" rel=\"nofollow\">Test Analytics</a>: Detect flaky tests, report on failures, and find test suite problems.</li>\n</ul>\n</details>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948479356",
"body": " I'm going to raise errors if you attempt to convert to STRICT with a SQLite version that fails the `db.supports_strict` test.",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948479356,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:31:51Z",
"updated_at": "2026-07-11T19:31:51Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">I'm going to raise errors if you attempt to convert to STRICT with a SQLite version that fails the <code class=\"notranslate\">db.supports_strict</code> test.</p>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948493701",
"body": "We don't have any mechanism to support `ANY` columns at the moment. Open question how to deal with that. Options include:\r\n\r\n- Ignore the problem entirely\r\n- Add a `sqlite_utils.ANY` constant which can be used in create table calls, e.g. `db.create_table(\"t\", {\"id\": int, \"name\": str, \"misc\": sqlite_utils.ANY})` - would have to be handled in `add_column()` and `transform()` and a bunch of other places too.\r\n- Don't support them in create_table/etc but DO support them in introspection, since that's part of how `transform()` works",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948493701,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:36:01Z",
"updated_at": "2026-07-11T19:36:01Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">We don't have any mechanism to support <code class=\"notranslate\">ANY</code> columns at the moment. Open question how to deal with that. Options include:</p>\n<ul dir=\"auto\">\n<li>Ignore the problem entirely</li>\n<li>Add a <code class=\"notranslate\">sqlite_utils.ANY</code> constant which can be used in create table calls, e.g. <code class=\"notranslate\">db.create_table(\"t\", {\"id\": int, \"name\": str, \"misc\": sqlite_utils.ANY})</code> - would have to be handled in <code class=\"notranslate\">add_column()</code> and <code class=\"notranslate\">transform()</code> and a bunch of other places too.</li>\n<li>Don't support them in create_table/etc but DO support them in introspection, since that's part of how <code class=\"notranslate\">transform()</code> works</li>\n</ul>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948505824",
"body": "Here's a reproduction of the problem where the default `strict` value for a table is not correctly updated:\r\n```python\r\nfrom sqlite_utils import Database\r\n\r\ndb = Database(memory=True)\r\n\r\ntable = db.table(\"items\", strict=True)\r\ntable.create({\"id\": int})\r\n\r\ntable.transform(strict=False)\r\nassert table.strict is False\r\n\r\n# Recreate using the same Table object's stale strict=True default:\r\ntable.create({\"id\": int}, replace=True)\r\n\r\nassert table.strict is True # Unexpectedly strict again\r\n```",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948505824,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:40:09Z",
"updated_at": "2026-07-11T19:40:09Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">Here's a reproduction of the problem where the default <code class=\"notranslate\">strict</code> value for a table is not correctly updated:</p>\n<div class=\"highlight highlight-source-python notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"from sqlite_utils import Database\n\ndb = Database(memory=True)\n\ntable = db.table("items", strict=True)\ntable.create({"id": int})\n\ntable.transform(strict=False)\nassert table.strict is False\n\n# Recreate using the same Table object's stale strict=True default:\ntable.create({"id": int}, replace=True)\n\nassert table.strict is True # Unexpectedly strict again\"><pre class=\"notranslate\"><span class=\"pl-k\">from</span> <span class=\"pl-s1\">sqlite_utils</span> <span class=\"pl-k\">import</span> <span class=\"pl-v\">Database</span>\n\n<span class=\"pl-s1\">db</span> <span class=\"pl-c1\">=</span> <span class=\"pl-en\">Database</span>(<span class=\"pl-s1\">memory</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n\n<span class=\"pl-s1\">table</span> <span class=\"pl-c1\">=</span> <span class=\"pl-s1\">db</span>.<span class=\"pl-c1\">table</span>(<span class=\"pl-s\">\"items\"</span>, <span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n<span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">create</span>({<span class=\"pl-s\">\"id\"</span>: <span class=\"pl-s1\">int</span>})\n\n<span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">transform</span>(<span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">False</span>)\n<span class=\"pl-k\">assert</span> <span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">strict</span> <span class=\"pl-c1\">is</span> <span class=\"pl-c1\">False</span>\n\n<span class=\"pl-c\"># Recreate using the same Table object's stale strict=True default:</span>\n<span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">create</span>({<span class=\"pl-s\">\"id\"</span>: <span class=\"pl-s1\">int</span>}, <span class=\"pl-s1\">replace</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n\n<span class=\"pl-k\">assert</span> <span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">strict</span> <span class=\"pl-c1\">is</span> <span class=\"pl-c1\">True</span> <span class=\"pl-c\"># Unexpectedly strict again</span></pre></div>"
}
],
"display_url": "https://github.com/simonw/sqlite-utils/pull/788",
"display_title": ".transform(strict=) and sqlite-utils transform --strict/--no-strict"
}MCP tool call
codex_apps.github.search_issues
{
"repository_full_name": "simonw/sqlite-utils",
"query": "created:>=2026-07-06",
"state": "closed",
"sort": "created",
"order": "desc",
"topn": 50
}Action completed.
{
"issues": [
{
"url": "https://github.com/simonw/sqlite-utils/issues/789",
"title": "Failing test test_memory_attribute_for_existing_connection with --sqlite-autocommit",
"issue_number": 789,
"body": "This command:\n```bash\nuv run pytest --sqlite-autocommit -k test_memory_attribute_for_existing_connection\n```\nFails like this:\n```\n autocommit = getattr(self.conn, \"autocommit\", None)\n if autocommit is not None and autocommit != getattr(\n sqlite3, \"LEGACY_TRANSACTION_CONTROL\", -1\n ):\n> raise TransactionError(\n \"sqlite-utils requires a connection that uses the default \"\n \"transaction handling - connections created with \"\n \"autocommit=True or autocommit=False are not supported\"\n )\nE sqlite_utils.db.TransactionError: sqlite-utils requires a connection that uses the default transaction handling - connections created with autocommit=True or autocommit=False are not supported\n```\nSince: d302835d57bcf53c36c0dc67356ec292f55a5931",
"state": null,
"user": null,
"assignees": null,
"labels": null,
"milestone": null,
"state_reason": null,
"comments": null,
"created_at": null,
"updated_at": null,
"closed_at": null,
"display_url": "https://github.com/simonw/sqlite-utils/issues/789",
"display_title": "Failing test test_memory_attribute_for_existing_connection with --sqlite-autocommit"
},
{
"url": "https://github.com/simonw/sqlite-utils/issues/783",
"title": "Regression: last_rowid is None after an ignored insert",
"issue_number": 783,
"body": "```python\ndb.execute(\"create table docs (id integer primary key, title text)\")\nt = db[\"docs\"]\nt.insert({\"id\": 1, \"title\": \"Exists\"}, pk=\"id\")\nr = t.insert({\"id\": 1, \"title\": \"One\"}, ignore=True) # datasette passes no pk=\nr.last_rowid # 4.0rc4: None | 3.39: 1\n```",
"state": null,
"user": null,
"assignees": null,
"labels": null,
"milestone": null,
"state_reason": null,
"comments": null,
"created_at": null,
"updated_at": null,
"closed_at": null,
"display_url": "https://github.com/simonw/sqlite-utils/issues/783",
"display_title": "Regression: last_rowid is None after an ignored insert"
},
{
"url": "https://github.com/simonw/sqlite-utils/issues/781",
"title": "Regression: rowid primary key rejected by upsert(pk=)",
"issue_number": 781,
"body": "Refs:\n- https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4900585395\n\nSTR:\n```python\nimport sqlite_utils\ndb = sqlite_utils.Database(memory=True)\ndb[\"t\"].insert({\"title\": \"Hello\"}) # rowid table, columns = ['title']\ndb[\"t\"].pks # -> ['rowid']\ndb[\"t\"].upsert({\"rowid\": 1, \"title\": \"x\"}, pk=\"rowid\")\n# 4.0rc4: InvalidColumns: Invalid primary key column ['rowid'] for table t with columns ['title']\n# 3.39: works\n",
"state": null,
"user": null,
"assignees": null,
"labels": null,
"milestone": null,
"state_reason": null,
"comments": null,
"created_at": null,
"updated_at": null,
"closed_at": null,
"display_url": "https://github.com/simonw/sqlite-utils/issues/781",
"display_title": "Regression: rowid primary key rejected by upsert(pk=)"
},
{
"url": "https://github.com/simonw/sqlite-utils/issues/778",
"title": "claude shared session link disabled",
"issue_number": 778,
"body": "Hi!\nwas trying to look at your convo with Fable (https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd) but unfortunately the link doesn't seem to work. I'm not sure if you're aware of this, or perhaps if it's by design, but i thought i would let you know anyway, just in case it matters.\nthank you!",
"state": null,
"user": null,
"assignees": null,
"labels": null,
"milestone": null,
"state_reason": null,
"comments": null,
"created_at": null,
"updated_at": null,
"closed_at": null,
"display_url": "https://github.com/simonw/sqlite-utils/issues/778",
"display_title": "claude shared session link disabled"
},
{
"url": "https://github.com/simonw/sqlite-utils/issues/774",
"title": "CI error: request to https://www.sqlite.org/2018/sqlite-amalgamation-3230100.zip failed",
"issue_number": 774,
"body": "Getting this error from the `asg017/sqlite-versions` action: https://github.com/simonw/sqlite-utils/actions/runs/28767547992/job/85294677795\n\n<img width=\"2862\" height=\"1948\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/35fb927a-75d0-46bb-9cd7-aa3b28c2364d\" />\n\nLooks like a timeout fetching from `sqlite.org` - for the moment I'm going to vendor `asg017/sqlite-versions` and switch it to a separate copy of the amalgamation.",
"state": null,
"user": null,
"assignees": null,
"labels": null,
"milestone": null,
"state_reason": null,
"comments": null,
"created_at": null,
"updated_at": null,
"closed_at": null,
"display_url": "https://github.com/simonw/sqlite-utils/issues/774",
"display_title": "CI error: request to https://www.sqlite.org/2018/sqlite-amalgamation-3230100.zip failed"
}
]
}MCP tool call
codex_apps.github.fetch_pr
{
"repo_full_name": "simonw/sqlite-utils",
"pr_number": 786
}Action completed.
{
"pull_request": {
"url": "https://github.com/simonw/sqlite-utils/pull/786",
"number": 786,
"state": "open",
"merged": false,
"mergeable": false,
"draft": false,
"body": "When a table has a column-level `UNIQUE` constraint (e.g. `name TEXT UNIQUE`), SQLite backs it with an auto-index whose `sql` column in `sqlite_master` is `NULL`. The index-rebuild step in `transform_sql()` treated any `NULL`-sql index as unrecoverable, raising `TransformError` and aborting the transform entirely. This adds a `unique` parameter to `create_table_sql` so the constraint is reproduced as an inline `UNIQUE` column attribute in the new `CREATE TABLE` statement; the index loop now skips auto-UNIQUE indices (`origin=\"u\"`) instead of raising. Column renames are applied to the constraint, and dropping a UNIQUE column drops its constraint silently. Fixes #762\n\n---\n_Generated by [Claude Code](https://claude.ai/code/session_01LPQoysxk76C5tD2CwHWgbr)_\r\n\r\n<!-- readthedocs-preview sqlite-utils start -->\r\n----\n📚 Documentation preview 📚: https://sqlite-utils--786.org.readthedocs.build/en/786/\n\r\n<!-- readthedocs-preview sqlite-utils end -->",
"title": "Fix .transform() raising TransformError on column-level UNIQUE constraints",
"base": "main",
"base_sha": "7a52214624ae0e2c3fdf07215c1bcfc1393dbd93",
"head": "claude/pensive-fermi-eyz071",
"head_sha": "4d6d51ad0259fa95643db379f9c382c67cc1dd95",
"merge_commit_sha": "7492551d5a9183851425e312134fde0037db7976",
"user": {
"login": "ikatyal2110",
"name": "ikatyal2110",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/134458944?v=4",
"id": 134458944
},
"requested_reviewers": null,
"requested_team_reviewers": null,
"diff": "@@ -1400,6 +1400,7 @@ def create_table_sql(\n extracts: Optional[Union[Dict[str, str], List[str]]] = None,\n if_not_exists: bool = False,\n strict: bool = False,\n+ unique: Optional[Iterable[str]] = None,\n ) -> str:\n \"\"\"\n Returns the SQL ``CREATE TABLE`` statement for creating the specified table.\n@@ -1442,6 +1443,7 @@ def create_table_sql(\n )\n # Soundness check not_null, and defaults if provided\n not_null = {resolve_casing(n, columns) for n in not_null or set()}\n+ unique = {resolve_casing(n, columns) for n in unique or set()}\n defaults = {resolve_casing(n, columns): v for n, v in (defaults or {}).items()}\n if column_order is not None:\n column_order = [resolve_casing(c, columns) for c in column_order]\n@@ -1498,6 +1500,8 @@ def sort_key(p):\n column_extras.append(\"PRIMARY KEY\")\n if column_name in not_null:\n column_extras.append(\"NOT NULL\")\n+ if column_name in unique and column_name != single_pk:\n+ column_extras.append(\"UNIQUE\")\n if column_name in defaults and defaults[column_name] is not None:\n column_extras.append(\n \"DEFAULT {}\".format(self.quote_default_value(defaults[column_name]))\n@@ -2796,6 +2800,16 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n if column_order is not None:\n column_order = [rename.get(col) or col for col in column_order]\n \n+ # Collect column-level UNIQUE constraints from auto-indices (origin=\"u\").\n+ # These have no CREATE INDEX SQL, so they must be reproduced as inline UNIQUE\n+ # column attributes in the new CREATE TABLE statement.\n+ create_table_unique = set()\n+ for index in self.indexes:\n+ if index.origin == \"u\":\n+ for col in index.columns:\n+ if col not in drop:\n+ create_table_unique.add(rename.get(col, col))\n+\n sqls = []\n sqls.append(\n self.db.create_table_sql(\n@@ -2807,6 +2821,7 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n foreign_keys=create_table_foreign_keys,\n column_order=column_order,\n strict=self.strict,\n+ unique=create_table_unique,\n ).strip()\n )\n \n@@ -2850,6 +2865,11 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n {\"index_name\": index.name},\n ).fetchall()[0][0]\n if index_sql is None:\n+ if index.origin == \"u\":\n+ # Auto-index backing a column-level UNIQUE constraint: already\n+ # reproduced as an inline UNIQUE attribute in the new table's\n+ # CREATE TABLE statement (see create_table_sql call above).\n+ continue\n raise TransformError(\n f\"Index '{index.name}' on table '{self.name}' does not have a \"\n \"CREATE INDEX statement. You must manually drop this index prior to running this \"\n@@ -681,15 +681,20 @@ def test_transform_with_unique_constraint_implicit_index(fresh_db):\n \"\"\")\n dogs.insert({\"id\": 1, \"name\": \"Cleo\", \"age\": 5})\n \n- # Attempt to transform the table without modifying 'name'\n- with pytest.raises(TransformError) as excinfo:\n- dogs.transform(types={\"age\": str})\n-\n- assert (\n- \"Index 'sqlite_autoindex_dogs_1' on table 'dogs' does not have a CREATE INDEX statement.\"\n- in str(excinfo.value)\n- )\n- assert (\n- \"You must manually drop this index prior to running this transformation and manually recreate the new index after running this transformation.\"\n- in str(excinfo.value)\n- )\n+ # Transform should succeed and preserve the UNIQUE constraint\n+ dogs.transform(types={\"age\": str})\n+ assert \"UNIQUE\" in dogs.schema\n+\n+ # Duplicate name insert should still be rejected\n+ import pytest as _pytest\n+ with _pytest.raises(Exception):\n+ fresh_db.execute(\"INSERT INTO dogs VALUES (2, 'Cleo', '6')\")\n+\n+ # Rename the UNIQUE column: constraint follows the new name\n+ dogs.transform(rename={\"name\": \"dog_name\"})\n+ assert \"UNIQUE\" in dogs.schema\n+ assert [c.name for c in dogs.columns] == [\"id\", \"dog_name\", \"age\"]\n+\n+ # Drop the UNIQUE column: transform completes without error\n+ dogs.transform(drop={\"dog_name\"})\n+ assert \"dog_name\" not in [c.name for c in dogs.columns]",
"comments": [],
"created_at": "2026-07-09T22:36:12Z",
"updated_at": "2026-07-09T22:36:21Z",
"closed_at": null,
"merged_at": null,
"commits": 1,
"changed_files": 2,
"additions": 37,
"deletions": 12,
"display_url": "https://github.com/simonw/sqlite-utils/pull/786",
"display_title": "Fix .transform() raising TransformError on column-level UNIQUE constraints"
},
"url": "https://github.com/simonw/sqlite-utils/pull/786",
"title": "Fix .transform() raising TransformError on column-level UNIQUE constraints",
"diff": "@@ -1400,6 +1400,7 @@ def create_table_sql(\n extracts: Optional[Union[Dict[str, str], List[str]]] = None,\n if_not_exists: bool = False,\n strict: bool = False,\n+ unique: Optional[Iterable[str]] = None,\n ) -> str:\n \"\"\"\n Returns the SQL ``CREATE TABLE`` statement for creating the specified table.\n@@ -1442,6 +1443,7 @@ def create_table_sql(\n )\n # Soundness check not_null, and defaults if provided\n not_null = {resolve_casing(n, columns) for n in not_null or set()}\n+ unique = {resolve_casing(n, columns) for n in unique or set()}\n defaults = {resolve_casing(n, columns): v for n, v in (defaults or {}).items()}\n if column_order is not None:\n column_order = [resolve_casing(c, columns) for c in column_order]\n@@ -1498,6 +1500,8 @@ def sort_key(p):\n column_extras.append(\"PRIMARY KEY\")\n if column_name in not_null:\n column_extras.append(\"NOT NULL\")\n+ if column_name in unique and column_name != single_pk:\n+ column_extras.append(\"UNIQUE\")\n if column_name in defaults and defaults[column_name] is not None:\n column_extras.append(\n \"DEFAULT {}\".format(self.quote_default_value(defaults[column_name]))\n@@ -2796,6 +2800,16 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n if column_order is not None:\n column_order = [rename.get(col) or col for col in column_order]\n \n+ # Collect column-level UNIQUE constraints from auto-indices (origin=\"u\").\n+ # These have no CREATE INDEX SQL, so they must be reproduced as inline UNIQUE\n+ # column attributes in the new CREATE TABLE statement.\n+ create_table_unique = set()\n+ for index in self.indexes:\n+ if index.origin == \"u\":\n+ for col in index.columns:\n+ if col not in drop:\n+ create_table_unique.add(rename.get(col, col))\n+\n sqls = []\n sqls.append(\n self.db.create_table_sql(\n@@ -2807,6 +2821,7 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n foreign_keys=create_table_foreign_keys,\n column_order=column_order,\n strict=self.strict,\n+ unique=create_table_unique,\n ).strip()\n )\n \n@@ -2850,6 +2865,11 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n {\"index_name\": index.name},\n ).fetchall()[0][0]\n if index_sql is None:\n+ if index.origin == \"u\":\n+ # Auto-index backing a column-level UNIQUE constraint: already\n+ # reproduced as an inline UNIQUE attribute in the new table's\n+ # CREATE TABLE statement (see create_table_sql call above).\n+ continue\n raise TransformError(\n f\"Index '{index.name}' on table '{self.name}' does not have a \"\n \"CREATE INDEX statement. You must manually drop this index prior to running this \"\n@@ -681,15 +681,20 @@ def test_transform_with_unique_constraint_implicit_index(fresh_db):\n \"\"\")\n dogs.insert({\"id\": 1, \"name\": \"Cleo\", \"age\": 5})\n \n- # Attempt to transform the table without modifying 'name'\n- with pytest.raises(TransformError) as excinfo:\n- dogs.transform(types={\"age\": str})\n-\n- assert (\n- \"Index 'sqlite_autoindex_dogs_1' on table 'dogs' does not have a CREATE INDEX statement.\"\n- in str(excinfo.value)\n- )\n- assert (\n- \"You must manually drop this index prior to running this transformation and manually recreate the new index after running this transformation.\"\n- in str(excinfo.value)\n- )\n+ # Transform should succeed and preserve the UNIQUE constraint\n+ dogs.transform(types={\"age\": str})\n+ assert \"UNIQUE\" in dogs.schema\n+\n+ # Duplicate name insert should still be rejected\n+ import pytest as _pytest\n+ with _pytest.raises(Exception):\n+ fresh_db.execute(\"INSERT INTO dogs VALUES (2, 'Cleo', '6')\")\n+\n+ # Rename the UNIQUE column: constraint follows the new name\n+ dogs.transform(rename={\"name\": \"dog_name\"})\n+ assert \"UNIQUE\" in dogs.schema\n+ assert [c.name for c in dogs.columns] == [\"id\", \"dog_name\", \"age\"]\n+\n+ # Drop the UNIQUE column: transform completes without error\n+ dogs.transform(drop={\"dog_name\"})\n+ assert \"dog_name\" not in [c.name for c in dogs.columns]",
"comments": null,
"display_url": "https://github.com/simonw/sqlite-utils/pull/786",
"display_title": "Fix .transform() raising TransformError on column-level UNIQUE constraints"
}MCP tool call
codex_apps.github.fetch_pr
{
"repo_full_name": "simonw/sqlite-utils",
"pr_number": 786
}Action completed.
{
"pull_request": {
"url": "https://github.com/simonw/sqlite-utils/pull/786",
"number": 786,
"state": "open",
"merged": false,
"mergeable": true,
"draft": false,
"body": "When a table has a column-level `UNIQUE` constraint (e.g. `name TEXT UNIQUE`), SQLite backs it with an auto-index whose `sql` column in `sqlite_master` is `NULL`. The index-rebuild step in `transform_sql()` treated any `NULL`-sql index as unrecoverable, raising `TransformError` and aborting the transform entirely. This adds a `unique` parameter to `create_table_sql` so the constraint is reproduced as an inline `UNIQUE` column attribute in the new `CREATE TABLE` statement; the index loop now skips auto-UNIQUE indices (`origin=\"u\"`) instead of raising. Column renames are applied to the constraint, and dropping a UNIQUE column drops its constraint silently. Fixes #762\n\n---\n_Generated by [Claude Code](https://claude.ai/code/session_01LPQoysxk76C5tD2CwHWgbr)_\r\n\r\n<!-- readthedocs-preview sqlite-utils start -->\r\n----\n📚 Documentation preview 📚: https://sqlite-utils--786.org.readthedocs.build/en/786/\n\r\n<!-- readthedocs-preview sqlite-utils end -->",
"title": "Fix .transform() raising TransformError on column-level UNIQUE constraints",
"base": "main",
"base_sha": "7a52214624ae0e2c3fdf07215c1bcfc1393dbd93",
"head": "claude/pensive-fermi-eyz071",
"head_sha": "4d6d51ad0259fa95643db379f9c382c67cc1dd95",
"merge_commit_sha": "be974c22d466037095a4912a425716066a295c93",
"user": {
"login": "ikatyal2110",
"name": "ikatyal2110",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/134458944?v=4",
"id": 134458944
},
"requested_reviewers": null,
"requested_team_reviewers": null,
"diff": "@@ -1400,6 +1400,7 @@ def create_table_sql(\n extracts: Optional[Union[Dict[str, str], List[str]]] = None,\n if_not_exists: bool = False,\n strict: bool = False,\n+ unique: Optional[Iterable[str]] = None,\n ) -> str:\n \"\"\"\n Returns the SQL ``CREATE TABLE`` statement for creating the specified table.\n@@ -1442,6 +1443,7 @@ def create_table_sql(\n )\n # Soundness check not_null, and defaults if provided\n not_null = {resolve_casing(n, columns) for n in not_null or set()}\n+ unique = {resolve_casing(n, columns) for n in unique or set()}\n defaults = {resolve_casing(n, columns): v for n, v in (defaults or {}).items()}\n if column_order is not None:\n column_order = [resolve_casing(c, columns) for c in column_order]\n@@ -1498,6 +1500,8 @@ def sort_key(p):\n column_extras.append(\"PRIMARY KEY\")\n if column_name in not_null:\n column_extras.append(\"NOT NULL\")\n+ if column_name in unique and column_name != single_pk:\n+ column_extras.append(\"UNIQUE\")\n if column_name in defaults and defaults[column_name] is not None:\n column_extras.append(\n \"DEFAULT {}\".format(self.quote_default_value(defaults[column_name]))\n@@ -2796,6 +2800,16 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n if column_order is not None:\n column_order = [rename.get(col) or col for col in column_order]\n \n+ # Collect column-level UNIQUE constraints from auto-indices (origin=\"u\").\n+ # These have no CREATE INDEX SQL, so they must be reproduced as inline UNIQUE\n+ # column attributes in the new CREATE TABLE statement.\n+ create_table_unique = set()\n+ for index in self.indexes:\n+ if index.origin == \"u\":\n+ for col in index.columns:\n+ if col not in drop:\n+ create_table_unique.add(rename.get(col, col))\n+\n sqls = []\n sqls.append(\n self.db.create_table_sql(\n@@ -2807,6 +2821,7 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n foreign_keys=create_table_foreign_keys,\n column_order=column_order,\n strict=self.strict,\n+ unique=create_table_unique,\n ).strip()\n )\n \n@@ -2850,6 +2865,11 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n {\"index_name\": index.name},\n ).fetchall()[0][0]\n if index_sql is None:\n+ if index.origin == \"u\":\n+ # Auto-index backing a column-level UNIQUE constraint: already\n+ # reproduced as an inline UNIQUE attribute in the new table's\n+ # CREATE TABLE statement (see create_table_sql call above).\n+ continue\n raise TransformError(\n f\"Index '{index.name}' on table '{self.name}' does not have a \"\n \"CREATE INDEX statement. You must manually drop this index prior to running this \"\n@@ -681,15 +681,20 @@ def test_transform_with_unique_constraint_implicit_index(fresh_db):\n \"\"\")\n dogs.insert({\"id\": 1, \"name\": \"Cleo\", \"age\": 5})\n \n- # Attempt to transform the table without modifying 'name'\n- with pytest.raises(TransformError) as excinfo:\n- dogs.transform(types={\"age\": str})\n-\n- assert (\n- \"Index 'sqlite_autoindex_dogs_1' on table 'dogs' does not have a CREATE INDEX statement.\"\n- in str(excinfo.value)\n- )\n- assert (\n- \"You must manually drop this index prior to running this transformation and manually recreate the new index after running this transformation.\"\n- in str(excinfo.value)\n- )\n+ # Transform should succeed and preserve the UNIQUE constraint\n+ dogs.transform(types={\"age\": str})\n+ assert \"UNIQUE\" in dogs.schema\n+\n+ # Duplicate name insert should still be rejected\n+ import pytest as _pytest\n+ with _pytest.raises(Exception):\n+ fresh_db.execute(\"INSERT INTO dogs VALUES (2, 'Cleo', '6')\")\n+\n+ # Rename the UNIQUE column: constraint follows the new name\n+ dogs.transform(rename={\"name\": \"dog_name\"})\n+ assert \"UNIQUE\" in dogs.schema\n+ assert [c.name for c in dogs.columns] == [\"id\", \"dog_name\", \"age\"]\n+\n+ # Drop the UNIQUE column: transform completes without error\n+ dogs.transform(drop={\"dog_name\"})\n+ assert \"dog_name\" not in [c.name for c in dogs.columns]",
"comments": [],
"created_at": "2026-07-09T22:36:12Z",
"updated_at": "2026-07-09T22:36:21Z",
"closed_at": null,
"merged_at": null,
"commits": 1,
"changed_files": 2,
"additions": 37,
"deletions": 12,
"display_url": "https://github.com/simonw/sqlite-utils/pull/786",
"display_title": "Fix .transform() raising TransformError on column-level UNIQUE constraints"
},
"url": "https://github.com/simonw/sqlite-utils/pull/786",
"title": "Fix .transform() raising TransformError on column-level UNIQUE constraints",
"diff": "@@ -1400,6 +1400,7 @@ def create_table_sql(\n extracts: Optional[Union[Dict[str, str], List[str]]] = None,\n if_not_exists: bool = False,\n strict: bool = False,\n+ unique: Optional[Iterable[str]] = None,\n ) -> str:\n \"\"\"\n Returns the SQL ``CREATE TABLE`` statement for creating the specified table.\n@@ -1442,6 +1443,7 @@ def create_table_sql(\n )\n # Soundness check not_null, and defaults if provided\n not_null = {resolve_casing(n, columns) for n in not_null or set()}\n+ unique = {resolve_casing(n, columns) for n in unique or set()}\n defaults = {resolve_casing(n, columns): v for n, v in (defaults or {}).items()}\n if column_order is not None:\n column_order = [resolve_casing(c, columns) for c in column_order]\n@@ -1498,6 +1500,8 @@ def sort_key(p):\n column_extras.append(\"PRIMARY KEY\")\n if column_name in not_null:\n column_extras.append(\"NOT NULL\")\n+ if column_name in unique and column_name != single_pk:\n+ column_extras.append(\"UNIQUE\")\n if column_name in defaults and defaults[column_name] is not None:\n column_extras.append(\n \"DEFAULT {}\".format(self.quote_default_value(defaults[column_name]))\n@@ -2796,6 +2800,16 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n if column_order is not None:\n column_order = [rename.get(col) or col for col in column_order]\n \n+ # Collect column-level UNIQUE constraints from auto-indices (origin=\"u\").\n+ # These have no CREATE INDEX SQL, so they must be reproduced as inline UNIQUE\n+ # column attributes in the new CREATE TABLE statement.\n+ create_table_unique = set()\n+ for index in self.indexes:\n+ if index.origin == \"u\":\n+ for col in index.columns:\n+ if col not in drop:\n+ create_table_unique.add(rename.get(col, col))\n+\n sqls = []\n sqls.append(\n self.db.create_table_sql(\n@@ -2807,6 +2821,7 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n foreign_keys=create_table_foreign_keys,\n column_order=column_order,\n strict=self.strict,\n+ unique=create_table_unique,\n ).strip()\n )\n \n@@ -2850,6 +2865,11 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n {\"index_name\": index.name},\n ).fetchall()[0][0]\n if index_sql is None:\n+ if index.origin == \"u\":\n+ # Auto-index backing a column-level UNIQUE constraint: already\n+ # reproduced as an inline UNIQUE attribute in the new table's\n+ # CREATE TABLE statement (see create_table_sql call above).\n+ continue\n raise TransformError(\n f\"Index '{index.name}' on table '{self.name}' does not have a \"\n \"CREATE INDEX statement. You must manually drop this index prior to running this \"\n@@ -681,15 +681,20 @@ def test_transform_with_unique_constraint_implicit_index(fresh_db):\n \"\"\")\n dogs.insert({\"id\": 1, \"name\": \"Cleo\", \"age\": 5})\n \n- # Attempt to transform the table without modifying 'name'\n- with pytest.raises(TransformError) as excinfo:\n- dogs.transform(types={\"age\": str})\n-\n- assert (\n- \"Index 'sqlite_autoindex_dogs_1' on table 'dogs' does not have a CREATE INDEX statement.\"\n- in str(excinfo.value)\n- )\n- assert (\n- \"You must manually drop this index prior to running this transformation and manually recreate the new index after running this transformation.\"\n- in str(excinfo.value)\n- )\n+ # Transform should succeed and preserve the UNIQUE constraint\n+ dogs.transform(types={\"age\": str})\n+ assert \"UNIQUE\" in dogs.schema\n+\n+ # Duplicate name insert should still be rejected\n+ import pytest as _pytest\n+ with _pytest.raises(Exception):\n+ fresh_db.execute(\"INSERT INTO dogs VALUES (2, 'Cleo', '6')\")\n+\n+ # Rename the UNIQUE column: constraint follows the new name\n+ dogs.transform(rename={\"name\": \"dog_name\"})\n+ assert \"UNIQUE\" in dogs.schema\n+ assert [c.name for c in dogs.columns] == [\"id\", \"dog_name\", \"age\"]\n+\n+ # Drop the UNIQUE column: transform completes without error\n+ dogs.transform(drop={\"dog_name\"})\n+ assert \"dog_name\" not in [c.name for c in dogs.columns]",
"comments": null,
"display_url": "https://github.com/simonw/sqlite-utils/pull/786",
"display_title": "Fix .transform() raising TransformError on column-level UNIQUE constraints"
}MCP tool call
codex_apps.github.fetch_pr
{
"repo_full_name": "simonw/sqlite-utils",
"pr_number": 785
}Action completed.
{
"pull_request": {
"url": "https://github.com/simonw/sqlite-utils/pull/785",
"number": 785,
"state": "open",
"merged": false,
"mergeable": true,
"draft": false,
"body": "When you run `.transform(types={\"age\": int})` on a table that has empty strings in a TEXT column, the empty strings currently survive as `\"\"` in the new INTEGER/FLOAT column instead of becoming NULL.\n\nThe fix wraps the SELECT expression with `NULLIF(col, '')` for any column explicitly being converted to a numeric type, so empty strings become NULL during the data copy step.\n\nText columns and columns not listed in `types` are not affected.\n\nFixes #488\r\n\r\n<!-- readthedocs-preview sqlite-utils start -->\r\n----\n📚 Documentation preview 📚: https://sqlite-utils--785.org.readthedocs.build/en/785/\n\r\n<!-- readthedocs-preview sqlite-utils end -->",
"title": "Fix transform to convert empty strings to NULL when changing to integer or float type",
"base": "main",
"base_sha": "7a52214624ae0e2c3fdf07215c1bcfc1393dbd93",
"head": "fix-transform-empty-string-to-null",
"head_sha": "d70c0e16a0dc2f37144266d0797c364e07855697",
"merge_commit_sha": "55f69dd9bf09cfbd350ea6afb3eb86769af1f829",
"user": {
"login": "ikatyal2110",
"name": "ikatyal2110",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/134458944?v=4",
"id": 134458944
},
"requested_reviewers": null,
"requested_team_reviewers": null,
"diff": "@@ -2820,10 +2820,24 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n if \"rowid\" not in new_cols:\n new_cols.insert(0, \"rowid\")\n old_cols.insert(0, \"rowid\")\n+ # Columns explicitly converted to a numeric type need NULLIF(col, '') so\n+ # that empty strings stored in a previously TEXT column become NULL rather\n+ # than being coerced to 0 or raising a type error.\n+ _numeric_kws = (\"INT\", \"REAL\", \"FLOA\", \"DOUB\", \"NUMERIC\", \"DECIMAL\")\n+\n+ def _col_expr(from_, to_):\n+ if from_ in types:\n+ raw = COLUMN_TYPE_MAPPING.get(types[from_])\n+ if raw is None and isinstance(types[from_], str):\n+ raw = types[from_]\n+ if raw and any(kw in raw.upper() for kw in _numeric_kws):\n+ return \"NULLIF({}, '')\".format(quote_identifier(from_))\n+ return quote_identifier(from_)\n+\n copy_sql = \"INSERT INTO {} ({new_cols})\\n SELECT {old_cols} FROM {};\".format(\n quote_identifier(new_table_name),\n quote_identifier(self.name),\n- old_cols=\", \".join(quote_identifier(col) for col in old_cols),\n+ old_cols=\", \".join(_col_expr(f, t) for f, t in zip(old_cols, new_cols)),\n new_cols=\", \".join(quote_identifier(col) for col in new_cols),\n )\n sqls.append(copy_sql)\n@@ -21,7 +21,7 @@\n {\"types\": {\"age\": int}},\n [\n 'CREATE TABLE \"dogs_new_suffix\" (\\n \"id\" INTEGER PRIMARY KEY,\\n \"name\" TEXT,\\n \"age\" INTEGER\\n);',\n- 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", \"age\" FROM \"dogs\";',\n+ 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", NULLIF(\"age\", \\'\\') FROM \"dogs\";',\n 'DROP TABLE \"dogs\";',\n 'ALTER TABLE \"dogs_new_suffix\" RENAME TO \"dogs\";',\n ],\n@@ -51,7 +51,7 @@\n {\"types\": {\"age\": int}, \"rename\": {\"age\": \"dog_age\"}},\n [\n 'CREATE TABLE \"dogs_new_suffix\" (\\n \"id\" INTEGER PRIMARY KEY,\\n \"name\" TEXT,\\n \"dog_age\" INTEGER\\n);',\n- 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"dog_age\")\\n SELECT \"rowid\", \"id\", \"name\", \"age\" FROM \"dogs\";',\n+ 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"dog_age\")\\n SELECT \"rowid\", \"id\", \"name\", NULLIF(\"age\", \\'\\') FROM \"dogs\";',\n 'DROP TABLE \"dogs\";',\n 'ALTER TABLE \"dogs_new_suffix\" RENAME TO \"dogs\";',\n ],\n@@ -144,7 +144,7 @@ def tracer(sql, params):\n {\"types\": {\"age\": int}},\n [\n 'CREATE TABLE \"dogs_new_suffix\" (\\n \"id\" INTEGER,\\n \"name\" TEXT,\\n \"age\" INTEGER\\n);',\n- 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", \"age\" FROM \"dogs\";',\n+ 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", NULLIF(\"age\", \\'\\') FROM \"dogs\";',\n 'DROP TABLE \"dogs\";',\n 'ALTER TABLE \"dogs_new_suffix\" RENAME TO \"dogs\";',\n ],\n@@ -669,6 +669,20 @@ def test_transform_with_indexes_errors(fresh_db, transform_params):\n )\n \n \n+def test_transform_converts_empty_strings_to_null_for_numeric_types(fresh_db):\n+ # Regression test for: transform should convert '' to NULL when changing\n+ # a TEXT column to an INTEGER or FLOAT type (issue #488).\n+ fresh_db[\"test\"].insert_all([\n+ {\"id\": \"1\", \"age\": \"3\", \"weight\": \"2.5\", \"name\": \"Alice\"},\n+ {\"id\": \"2\", \"age\": \"\", \"weight\": \"\", \"name\": \"\"},\n+ ])\n+ fresh_db[\"test\"].transform(types={\"age\": int, \"weight\": float})\n+ rows = list(fresh_db[\"test\"].rows)\n+ assert rows[0] == {\"id\": \"1\", \"age\": 3, \"weight\": 2.5, \"name\": \"Alice\"}\n+ # Empty strings in numeric columns become NULL; text columns are unchanged\n+ assert rows[1] == {\"id\": \"2\", \"age\": None, \"weight\": None, \"name\": \"\"}\n+\n+\n def test_transform_with_unique_constraint_implicit_index(fresh_db):\n dogs = fresh_db[\"dogs\"]\n # Create a table with a UNIQUE constraint on 'name', which creates an implicit index",
"comments": [],
"created_at": "2026-07-08T22:22:13Z",
"updated_at": "2026-07-08T22:22:26Z",
"closed_at": null,
"merged_at": null,
"commits": 1,
"changed_files": 2,
"additions": 32,
"deletions": 4,
"display_url": "https://github.com/simonw/sqlite-utils/pull/785",
"display_title": "Fix transform to convert empty strings to NULL when changing to integer or float type"
},
"url": "https://github.com/simonw/sqlite-utils/pull/785",
"title": "Fix transform to convert empty strings to NULL when changing to integer or float type",
"diff": "@@ -2820,10 +2820,24 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n if \"rowid\" not in new_cols:\n new_cols.insert(0, \"rowid\")\n old_cols.insert(0, \"rowid\")\n+ # Columns explicitly converted to a numeric type need NULLIF(col, '') so\n+ # that empty strings stored in a previously TEXT column become NULL rather\n+ # than being coerced to 0 or raising a type error.\n+ _numeric_kws = (\"INT\", \"REAL\", \"FLOA\", \"DOUB\", \"NUMERIC\", \"DECIMAL\")\n+\n+ def _col_expr(from_, to_):\n+ if from_ in types:\n+ raw = COLUMN_TYPE_MAPPING.get(types[from_])\n+ if raw is None and isinstance(types[from_], str):\n+ raw = types[from_]\n+ if raw and any(kw in raw.upper() for kw in _numeric_kws):\n+ return \"NULLIF({}, '')\".format(quote_identifier(from_))\n+ return quote_identifier(from_)\n+\n copy_sql = \"INSERT INTO {} ({new_cols})\\n SELECT {old_cols} FROM {};\".format(\n quote_identifier(new_table_name),\n quote_identifier(self.name),\n- old_cols=\", \".join(quote_identifier(col) for col in old_cols),\n+ old_cols=\", \".join(_col_expr(f, t) for f, t in zip(old_cols, new_cols)),\n new_cols=\", \".join(quote_identifier(col) for col in new_cols),\n )\n sqls.append(copy_sql)\n@@ -21,7 +21,7 @@\n {\"types\": {\"age\": int}},\n [\n 'CREATE TABLE \"dogs_new_suffix\" (\\n \"id\" INTEGER PRIMARY KEY,\\n \"name\" TEXT,\\n \"age\" INTEGER\\n);',\n- 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", \"age\" FROM \"dogs\";',\n+ 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", NULLIF(\"age\", \\'\\') FROM \"dogs\";',\n 'DROP TABLE \"dogs\";',\n 'ALTER TABLE \"dogs_new_suffix\" RENAME TO \"dogs\";',\n ],\n@@ -51,7 +51,7 @@\n {\"types\": {\"age\": int}, \"rename\": {\"age\": \"dog_age\"}},\n [\n 'CREATE TABLE \"dogs_new_suffix\" (\\n \"id\" INTEGER PRIMARY KEY,\\n \"name\" TEXT,\\n \"dog_age\" INTEGER\\n);',\n- 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"dog_age\")\\n SELECT \"rowid\", \"id\", \"name\", \"age\" FROM \"dogs\";',\n+ 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"dog_age\")\\n SELECT \"rowid\", \"id\", \"name\", NULLIF(\"age\", \\'\\') FROM \"dogs\";',\n 'DROP TABLE \"dogs\";',\n 'ALTER TABLE \"dogs_new_suffix\" RENAME TO \"dogs\";',\n ],\n@@ -144,7 +144,7 @@ def tracer(sql, params):\n {\"types\": {\"age\": int}},\n [\n 'CREATE TABLE \"dogs_new_suffix\" (\\n \"id\" INTEGER,\\n \"name\" TEXT,\\n \"age\" INTEGER\\n);',\n- 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", \"age\" FROM \"dogs\";',\n+ 'INSERT INTO \"dogs_new_suffix\" (\"rowid\", \"id\", \"name\", \"age\")\\n SELECT \"rowid\", \"id\", \"name\", NULLIF(\"age\", \\'\\') FROM \"dogs\";',\n 'DROP TABLE \"dogs\";',\n 'ALTER TABLE \"dogs_new_suffix\" RENAME TO \"dogs\";',\n ],\n@@ -669,6 +669,20 @@ def test_transform_with_indexes_errors(fresh_db, transform_params):\n )\n \n \n+def test_transform_converts_empty_strings_to_null_for_numeric_types(fresh_db):\n+ # Regression test for: transform should convert '' to NULL when changing\n+ # a TEXT column to an INTEGER or FLOAT type (issue #488).\n+ fresh_db[\"test\"].insert_all([\n+ {\"id\": \"1\", \"age\": \"3\", \"weight\": \"2.5\", \"name\": \"Alice\"},\n+ {\"id\": \"2\", \"age\": \"\", \"weight\": \"\", \"name\": \"\"},\n+ ])\n+ fresh_db[\"test\"].transform(types={\"age\": int, \"weight\": float})\n+ rows = list(fresh_db[\"test\"].rows)\n+ assert rows[0] == {\"id\": \"1\", \"age\": 3, \"weight\": 2.5, \"name\": \"Alice\"}\n+ # Empty strings in numeric columns become NULL; text columns are unchanged\n+ assert rows[1] == {\"id\": \"2\", \"age\": None, \"weight\": None, \"name\": \"\"}\n+\n+\n def test_transform_with_unique_constraint_implicit_index(fresh_db):\n dogs = fresh_db[\"dogs\"]\n # Create a table with a UNIQUE constraint on 'name', which creates an implicit index",
"comments": null,
"display_url": "https://github.com/simonw/sqlite-utils/pull/785",
"display_title": "Fix transform to convert empty strings to NULL when changing to integer or float type"
}MCP tool call
codex_apps.github.fetch_pr
{
"repo_full_name": "simonw/sqlite-utils",
"pr_number": 788
}Action completed.
{
"pull_request": {
"url": "https://github.com/simonw/sqlite-utils/pull/788",
"number": 788,
"state": "open",
"merged": false,
"mergeable": true,
"draft": true,
"body": "Refs:\r\n- #787\r\n\r\n<!-- readthedocs-preview sqlite-utils start -->\r\n----\n📚 Documentation preview 📚: https://sqlite-utils--788.org.readthedocs.build/en/788/\n\r\n<!-- readthedocs-preview sqlite-utils end -->",
"title": ".transform(strict=) and sqlite-utils transform --strict/--no-strict",
"base": "main",
"base_sha": "6531a57863ce23d502e504fd8fcd375fbe5cbb7f",
"head": "transform-strict",
"head_sha": "989729d5ed145092637385e9c426cc5ff80f7a10",
"merge_commit_sha": "ca37e4e0b0f0541d621f0af6635c1da97240315b",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"requested_reviewers": null,
"requested_team_reviewers": null,
"diff": "@@ -9,6 +9,8 @@\n Unreleased\n ----------\n \n+- ``table.transform()`` and ``table.transform_sql()`` now accept ``strict=True`` or ``strict=False`` to change a table's SQLite strict mode. Omitting the option, or passing ``strict=None``, preserves the existing mode. (:issue:`787`)\n+- The ``sqlite-utils transform`` command now accepts ``--strict`` and ``--no-strict`` to change a table's SQLite strict mode. Omitting both options preserves the existing mode. (:issue:`787`)\n - ``sqlite-utils query`` can now read the SQL query from standard input by passing ``-`` in place of the query, for example ``echo \"select * from dogs\" | sqlite-utils query dogs.db -``. (:issue:`765`)\n - ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for :ref:`providing a block of Python code <cli_insert_code>` (or a path to a ``.py`` file) that defines a ``rows()`` function or ``rows`` iterable of rows to insert, as an alternative to importing from a file. (:issue:`684`)\n - ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept ``--type column-name type`` to :ref:`override the type automatically chosen when the table is created <cli_insert_csv_tsv_column_types>`. This is useful for CSV or TSV columns such as ZIP codes that look like integers but should be stored as ``TEXT`` to preserve leading zeros. (:issue:`131`)\n@@ -508,6 +508,8 @@ See :ref:`cli_transform_table`.\n Add a foreign key constraint from a column to\n another table with another column\n --drop-foreign-key TEXT Drop foreign key constraint for this column\n+ --strict / --no-strict Enable or disable STRICT mode (default:\n+ preserve current mode)\n --sql Output SQL without executing it\n --load-extension TEXT Path to SQLite extension, with optional\n :entrypoint\n@@ -2182,7 +2182,7 @@ Use ``--ignore`` to ignore the error if the table does not exist.\n Transforming tables\n ===================\n \n-The ``transform`` command allows you to apply complex transformations to a table that cannot be implemented using a regular SQLite ``ALTER TABLE`` command. See :ref:`python_api_transform` for details of how this works. The ``transform`` command preserves a table's ``STRICT`` mode.\n+The ``transform`` command allows you to apply complex transformations to a table that cannot be implemented using a regular SQLite ``ALTER TABLE`` command. See :ref:`python_api_transform` for details of how this works. By default, the ``transform`` command preserves a table's ``STRICT`` mode.\n \n .. code-block:: bash\n \n@@ -2228,6 +2228,12 @@ Every option for this table (with the exception of ``--pk-none``) can be specifi\n ``--add-foreign-key column other_table other_column``\n Add a foreign key constraint to ``column`` pointing to ``other_table.other_column``.\n \n+``--strict``\n+ Convert the table to a `SQLite STRICT table <https://www.sqlite.org/stricttables.html>`__. The command fails if the available SQLite version does not support strict tables. If existing rows contain values that are incompatible with their declared column types the transformation fails and the original table is left unchanged.\n+\n+``--no-strict``\n+ Convert a strict table back to a regular non-strict table.\n+\n If you want to see the SQL that will be executed to make the change without actually executing it, add the ``--sql`` flag. For example:\n \n .. code-block:: bash\n@@ -1753,6 +1753,29 @@ To alter the type of a column, use the ``types=`` argument:\n \n See :ref:`python_api_add_column` for a list of available types.\n \n+.. _python_api_transform_strict:\n+\n+Changing strict mode\n+--------------------\n+\n+The optional ``strict=`` parameter can change whether a table uses `SQLite STRICT mode <https://www.sqlite.org/stricttables.html>`__. Pass ``strict=True`` to convert a regular table to a strict table:\n+\n+.. code-block:: python\n+\n+ table.transform(strict=True)\n+\n+Pass ``strict=False`` to convert a strict table back to a regular non-strict table:\n+\n+.. code-block:: python\n+\n+ table.transform(strict=False)\n+\n+The default is ``strict=None``, which preserves the table's existing strict mode.\n+\n+Passing ``strict=True`` raises ``sqlite_utils.db.TransformError`` if the available SQLite version does not support strict tables.\n+\n+Converting to a strict table validates all existing rows as they are copied into the replacement table. If a value is incompatible with its declared column type, SQLite raises ``sqlite3.IntegrityError`` and the transformation is rolled back, leaving the original table and its data unchanged.\n+\n .. _python_api_transform_rename_columns:\n \n Renaming columns\n@@ -2718,6 +2718,11 @@ def schema(\n multiple=True,\n help=\"Drop foreign key constraint for this column\",\n )\n+@click.option(\n+ \"--strict/--no-strict\",\n+ default=None,\n+ help=\"Enable or disable STRICT mode (default: preserve current mode)\",\n+)\n @click.option(\"--sql\", is_flag=True, help=\"Output SQL without executing it\")\n @load_extension_option\n def transform(\n@@ -2735,6 +2740,7 @@ def transform(\n default_none,\n add_foreign_keys,\n drop_foreign_keys,\n+ strict,\n sql,\n load_extension,\n ):\n@@ -2796,6 +2802,7 @@ def transform(\n defaults=default_dict,\n drop_foreign_keys=drop_foreign_keys_value,\n add_foreign_keys=add_foreign_keys_value,\n+ strict=strict,\n ):\n click.echo(line)\n else:\n@@ -2809,6 +2816,7 @@ def transform(\n defaults=default_dict,\n drop_foreign_keys=drop_foreign_keys_value,\n add_foreign_keys=add_foreign_keys_value,\n+ strict=strict,\n )\n \n \n@@ -2514,6 +2514,7 @@ def transform(\n foreign_keys: Optional[ForeignKeysType] = None,\n column_order: Optional[List[str]] = None,\n keep_table: Optional[str] = None,\n+ strict: Optional[bool] = None,\n ) -> \"Table\":\n \"\"\"\n Apply an advanced alter table, including operations that are not supported by\n@@ -2536,6 +2537,8 @@ def transform(\n to use when creating the table\n :param keep_table: If specified, the existing table will be renamed to this and will not be\n dropped\n+ :param strict: Set to ``True`` to make the table strict or ``False`` to make it\n+ non-strict. Defaults to ``None``, which preserves the existing strict mode.\n \"\"\"\n if not self.exists():\n raise ValueError(\"Cannot transform a table that doesn't exist yet\")\n@@ -2551,6 +2554,7 @@ def transform(\n foreign_keys=foreign_keys,\n column_order=column_order,\n keep_table=keep_table,\n+ strict=strict,\n )\n pragma_foreign_keys_was_on = bool(\n self.db.execute(\"PRAGMA foreign_keys\").fetchone()[0]\n@@ -2587,6 +2591,8 @@ def transform(\n self.db.execute(\"PRAGMA defer_foreign_keys=OFF;\")\n if should_disable_foreign_keys:\n self.db.execute(\"PRAGMA foreign_keys=1;\")\n+ if strict is not None:\n+ self._defaults[\"strict\"] = strict\n return self\n \n def transform_sql(\n@@ -2604,6 +2610,7 @@ def transform_sql(\n column_order: Optional[List[str]] = None,\n tmp_suffix: Optional[str] = None,\n keep_table: Optional[str] = None,\n+ strict: Optional[bool] = None,\n ) -> List[str]:\n \"\"\"\n Return a list of SQL statements that should be executed in order to apply this transformation.\n@@ -2624,7 +2631,11 @@ def transform_sql(\n :param tmp_suffix: Suffix to use for the temporary table name\n :param keep_table: If specified, the existing table will be renamed to this and will not be\n dropped\n+ :param strict: Set to ``True`` to make the table strict or ``False`` to make it\n+ non-strict. Defaults to ``None``, which preserves the existing strict mode.\n \"\"\"\n+ if strict is True and not self.db.supports_strict:\n+ raise TransformError(\"SQLite does not support STRICT tables\")\n types = types or {}\n rename = rename or {}\n drop = drop or set()\n@@ -2806,7 +2817,7 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n defaults=create_table_defaults,\n foreign_keys=create_table_foreign_keys,\n column_order=column_order,\n- strict=self.strict,\n+ strict=self.strict if strict is None else strict,\n ).strip()\n )\n \n@@ -3,6 +3,7 @@\n from click.testing import CliRunner\n from pathlib import Path\n import subprocess\n+import sqlite3\n import sys\n import json\n import os\n@@ -1939,6 +1940,64 @@ def test_transform_sql(db_path):\n assert db[\"dogs\"].schema == original_schema\n \n \n+@pytest.mark.parametrize(\n+ \"initial_strict,args,expected_strict\",\n+ (\n+ (False, [], False),\n+ (True, [], True),\n+ (False, [\"--strict\"], True),\n+ (True, [\"--no-strict\"], False),\n+ ),\n+)\n+def test_transform_strict_option(db_path, initial_strict, args, expected_strict):\n+ db = Database(db_path)\n+ if not db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ db[\"dogs\"].create({\"id\": int}, strict=initial_strict)\n+\n+ result = CliRunner().invoke(cli.cli, [\"transform\", db_path, \"dogs\"] + args)\n+\n+ assert result.exit_code == 0, result.output\n+ assert db[\"dogs\"].strict is expected_strict\n+\n+\n+@pytest.mark.parametrize(\n+ \"initial_strict,flag,sql_is_strict\",\n+ (\n+ (False, \"--strict\", True),\n+ (True, \"--no-strict\", False),\n+ ),\n+)\n+def test_transform_strict_option_sql(db_path, initial_strict, flag, sql_is_strict):\n+ db = Database(db_path)\n+ if not db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ db[\"dogs\"].create({\"id\": int}, strict=initial_strict)\n+\n+ result = CliRunner().invoke(cli.cli, [\"transform\", db_path, \"dogs\", flag, \"--sql\"])\n+\n+ assert result.exit_code == 0, result.output\n+ assert (\") STRICT;\" in result.output) is sql_is_strict\n+ assert db[\"dogs\"].strict is initial_strict\n+\n+\n+def test_transform_strict_option_with_invalid_data(db_path):\n+ db = Database(db_path)\n+ if not db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ dogs = db[\"dogs\"]\n+ dogs.create({\"id\": int})\n+ dogs.insert({\"id\": \"not-an-integer\"})\n+\n+ result = CliRunner().invoke(cli.cli, [\"transform\", db_path, \"dogs\", \"--strict\"])\n+\n+ assert result.exit_code == 1\n+ assert isinstance(result.exception, sqlite3.IntegrityError)\n+ assert dogs.strict is False\n+ assert list(dogs.rows) == [{\"id\": \"not-an-integer\"}]\n+ assert not any(name.startswith(\"dogs_new_\") for name in db.table_names())\n+\n+\n @pytest.mark.parametrize(\n \"extra_args,expected_schema\",\n (\n@@ -1,3 +1,5 @@\n+import sqlite3\n+\n from sqlite_utils.db import ForeignKey, TransformError\n from sqlite_utils.utils import OperationalError\n import pytest\n@@ -566,13 +568,63 @@ def test_transform_preserves_rowids(fresh_db, table_type):\n assert previous_rows == next_rows\n \n \n-@pytest.mark.parametrize(\"strict\", (False, True))\n-def test_transform_strict(fresh_db, strict):\n- dogs = fresh_db.table(\"dogs\", strict=strict)\n+@pytest.mark.parametrize(\n+ \"initial_strict,transform_strict,expected_strict\",\n+ (\n+ (False, None, False),\n+ (True, None, True),\n+ (False, True, True),\n+ (True, False, False),\n+ ),\n+)\n+def test_transform_strict(fresh_db, initial_strict, transform_strict, expected_strict):\n+ if not fresh_db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ dogs = fresh_db.table(\"dogs\", strict=initial_strict)\n dogs.insert({\"id\": 1, \"name\": \"Cleo\"})\n- assert dogs.strict == strict or not fresh_db.supports_strict\n- dogs.transform(not_null={\"name\"})\n- assert dogs.strict == strict or not fresh_db.supports_strict\n+ assert dogs.strict is initial_strict\n+ dogs.transform(strict=transform_strict)\n+ assert dogs.strict is expected_strict\n+\n+\n+def test_transform_to_strict_with_invalid_data(fresh_db):\n+ if not fresh_db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ dogs = fresh_db[\"dogs\"]\n+ dogs.create({\"id\": int})\n+ dogs.insert({\"id\": \"not-an-integer\"})\n+\n+ with pytest.raises(sqlite3.IntegrityError):\n+ dogs.transform(strict=True)\n+\n+ assert dogs.strict is False\n+ assert list(dogs.rows) == [{\"id\": \"not-an-integer\"}]\n+ assert fresh_db.table_names() == [\"dogs\"]\n+\n+\n+def test_transform_strict_updates_default(fresh_db):\n+ if not fresh_db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ table = fresh_db.table(\"items\", strict=True)\n+ table.create({\"id\": int})\n+\n+ table.transform(strict=False)\n+ assert table.strict is False\n+\n+ table.create({\"id\": int}, replace=True)\n+ assert table.strict is False\n+\n+\n+@pytest.mark.parametrize(\"method_name\", (\"transform\", \"transform_sql\"))\n+def test_transform_to_strict_not_supported(fresh_db, method_name):\n+ table = fresh_db[\"items\"]\n+ table.create({\"id\": int})\n+ fresh_db._supports_strict = False\n+\n+ with pytest.raises(TransformError, match=\"SQLite does not support STRICT tables\"):\n+ getattr(table, method_name)(strict=True)\n+\n+ assert table.strict is False\n \n \n @pytest.mark.parametrize(",
"comments": [
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948473657",
"body": "> use `uv run python -c` and manually exercise the new `.transform(strict=`) option, see if you can find any edge-cases or bugs\r\n\r\nGPT-5.6 Sol then found some edge-cases:\r\n\r\n> Manual probing found two bugs directly relevant to the new option.\r\n> \r\n> 1. Explicit `strict=True` silently fails on unsupported SQLite\r\n> \r\n> Simulating an older SQLite version produced:\r\n> \r\n> ```python\r\n> t.transform(strict=True)\r\n> t.strict # False\r\n> ```\r\n> \r\n> `create_table_sql()` only appends `STRICT` when `db.supports_strict` is true ([db.py](/Users/simon/Dropbox/dev/sqlite-utils/sqlite_utils/db.py:1554)). An explicit conversion should probably raise an informative error instead of succeeding as a no-op.\r\n> \r\n> 2. The `Table` object retains stale strict defaults\r\n> \r\n> After:\r\n> \r\n> ```python\r\n> t = db.table(\"items\", strict=True)\r\n> t.create({\"id\": int})\r\n> t.transform(strict=False)\r\n> ```\r\n> \r\n> The schema becomes non-strict, but `t._defaults[\"strict\"]` remains `True`. Dropping and recreating through the same object unexpectedly creates another strict table. The reverse happens when converting from non-strict to strict.\r\n> \r\n> The default should be updated after a successful transformation, including storing `False`. It should remain unchanged if the transformation rolls back.\r\n> \r\n> Other notable edge cases:\r\n> \r\n> - SQLite `ANY` columns are introspected as `float`, so transforming a native strict table containing `ANY` can fail or change it to `REAL`. This is an existing transform/introspection limitation, but applies to `strict=None` and `strict=True`.\r\n> - An incompatible declared default does not prevent conversion. An empty `INTEGER DEFAULT 'not-an-integer'` table becomes strict successfully, but later inserting the default raises `IntegrityError`. This is SQLite behavior.\r\n> - Triggers are dropped during transformation. This is existing documented behavior, not introduced by this change.\r\n> - Losslessly convertible values work: `\"123\"` and `\"123.0\"` become integer `123`.\r\n> - Fractional numbers, incompatible text, and BLOBs correctly fail and roll back.\r\n> - Indexes and foreign keys were preserved.\r\n> - Failed conversion inside an existing `db.atomic()` transaction rolled back only the transform; the outer transaction remained usable.\r\n> - `keep_table` correctly retained the original non-strict table alongside the new strict table.\r\n> - Repeated `None → True → None → False → None` transitions behaved correctly.\r\n> \r\n> No files were changed during this diagnostic pass.",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948473657,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:30:03Z",
"updated_at": "2026-07-11T19:30:03Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<blockquote>\n<p dir=\"auto\">use <code class=\"notranslate\">uv run python -c</code> and manually exercise the new <code class=\"notranslate\">.transform(strict=</code>) option, see if you can find any edge-cases or bugs</p>\n</blockquote>\n<p dir=\"auto\">GPT-5.6 Sol then found some edge-cases:</p>\n<blockquote>\n<p dir=\"auto\">Manual probing found two bugs directly relevant to the new option.</p>\n<ol dir=\"auto\">\n<li>Explicit <code class=\"notranslate\">strict=True</code> silently fails on unsupported SQLite</li>\n</ol>\n<p dir=\"auto\">Simulating an older SQLite version produced:</p>\n<div class=\"highlight highlight-source-python notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"t.transform(strict=True)\nt.strict # False\"><pre class=\"notranslate\"><span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">transform</span>(<span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n<span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">strict</span> <span class=\"pl-c\"># False</span></pre></div>\n<p dir=\"auto\"><code class=\"notranslate\">create_table_sql()</code> only appends <code class=\"notranslate\">STRICT</code> when <code class=\"notranslate\">db.supports_strict</code> is true (<a href=\"/Users/simon/Dropbox/dev/sqlite-utils/sqlite_utils/db.py:1554\">db.py</a>). An explicit conversion should probably raise an informative error instead of succeeding as a no-op.</p>\n<ol start=\"2\" dir=\"auto\">\n<li>The <code class=\"notranslate\">Table</code> object retains stale strict defaults</li>\n</ol>\n<p dir=\"auto\">After:</p>\n<div class=\"highlight highlight-source-python notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"t = db.table("items", strict=True)\nt.create({"id": int})\nt.transform(strict=False)\"><pre class=\"notranslate\"><span class=\"pl-s1\">t</span> <span class=\"pl-c1\">=</span> <span class=\"pl-s1\">db</span>.<span class=\"pl-c1\">table</span>(<span class=\"pl-s\">\"items\"</span>, <span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n<span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">create</span>({<span class=\"pl-s\">\"id\"</span>: <span class=\"pl-s1\">int</span>})\n<span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">transform</span>(<span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">False</span>)</pre></div>\n<p dir=\"auto\">The schema becomes non-strict, but <code class=\"notranslate\">t._defaults[\"strict\"]</code> remains <code class=\"notranslate\">True</code>. Dropping and recreating through the same object unexpectedly creates another strict table. The reverse happens when converting from non-strict to strict.</p>\n<p dir=\"auto\">The default should be updated after a successful transformation, including storing <code class=\"notranslate\">False</code>. It should remain unchanged if the transformation rolls back.</p>\n<p dir=\"auto\">Other notable edge cases:</p>\n<ul dir=\"auto\">\n<li>SQLite <code class=\"notranslate\">ANY</code> columns are introspected as <code class=\"notranslate\">float</code>, so transforming a native strict table containing <code class=\"notranslate\">ANY</code> can fail or change it to <code class=\"notranslate\">REAL</code>. This is an existing transform/introspection limitation, but applies to <code class=\"notranslate\">strict=None</code> and <code class=\"notranslate\">strict=True</code>.</li>\n<li>An incompatible declared default does not prevent conversion. An empty <code class=\"notranslate\">INTEGER DEFAULT 'not-an-integer'</code> table becomes strict successfully, but later inserting the default raises <code class=\"notranslate\">IntegrityError</code>. This is SQLite behavior.</li>\n<li>Triggers are dropped during transformation. This is existing documented behavior, not introduced by this change.</li>\n<li>Losslessly convertible values work: <code class=\"notranslate\">\"123\"</code> and <code class=\"notranslate\">\"123.0\"</code> become integer <code class=\"notranslate\">123</code>.</li>\n<li>Fractional numbers, incompatible text, and BLOBs correctly fail and roll back.</li>\n<li>Indexes and foreign keys were preserved.</li>\n<li>Failed conversion inside an existing <code class=\"notranslate\">db.atomic()</code> transaction rolled back only the transform; the outer transaction remained usable.</li>\n<li><code class=\"notranslate\">keep_table</code> correctly retained the original non-strict table alongside the new strict table.</li>\n<li>Repeated <code class=\"notranslate\">None → True → None → False → None</code> transitions behaved correctly.</li>\n</ul>\n<p dir=\"auto\">No files were changed during this diagnostic pass.</p>\n</blockquote>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948475411",
"body": "## [Codecov](https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison) Report\n:white_check_mark: All modified and coverable lines are covered by tests.\n:white_check_mark: Project coverage is 95.54%. Comparing base ([`6531a57`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/6531a57863ce23d502e504fd8fcd375fbe5cbb7f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)) to head ([`5b9898e`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/5b9898ed9b5f71d45d85001543363cd48eadd646?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)).\n\n<details><summary>Additional details and impacted files</summary>\n\n\n\n```diff\n@@ Coverage Diff @@\n## main #788 +/- ##\n=======================================\n Coverage 95.54% 95.54% \n=======================================\n Files 9 9 \n Lines 3793 3796 +3 \n=======================================\n+ Hits 3624 3627 +3 \n Misses 169 169 \n```\n</details>\n\n[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison). \n:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison).\n<details><summary> :rocket: New features to boost your workflow: </summary>\n\n- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.\n</details>",
"user": {
"login": "codecov[bot]",
"name": "codecov[bot]",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/in/254?v=4",
"id": 22429695
},
"id": 4948475411,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:30:37Z",
"updated_at": "2026-07-11T23:33:48Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<h2 dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Codecov</a> Report</h2>\n<p dir=\"auto\">✅ All modified and coverable lines are covered by tests.<br>\n✅ Project coverage is 95.54%. Comparing base (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/6531a57863ce23d502e504fd8fcd375fbe5cbb7f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">6531a57</code></a>) to head (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/5b9898ed9b5f71d45d85001543363cd48eadd646?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">5b9898e</code></a>).</p>\n<details><summary>Additional details and impacted files</summary>\n<div class=\"highlight highlight-source-diff notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"@@ Coverage Diff @@\n## main #788 +/- ##\n=======================================\n Coverage 95.54% 95.54% \n=======================================\n Files 9 9 \n Lines 3793 3796 +3 \n=======================================\n+ Hits 3624 3627 +3 \n Misses 169 169 \"><pre class=\"notranslate\"><span class=\"pl-mdr\">@@ Coverage Diff @@</span>\n<span class=\"pl-c\"><span class=\"pl-c\">#</span># main #788 +/- ##</span>\n=======================================\n Coverage 95.54% 95.54% \n=======================================\n Files 9 9 \n Lines 3793 3796 +3 \n=======================================\n<span class=\"pl-mi1\"><span class=\"pl-mi1\">+</span> Hits 3624 3627 +3 </span>\n Misses 169 169 </pre></div>\n</details>\n<p dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">☔ View full report in Codecov by Harness</a>.<br>\n📢 Have feedback on the report? <a href=\"https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Share it here</a>.</p>\n<details><summary> 🚀 New features to boost your workflow: </summary>\n<ul dir=\"auto\">\n<li>❄️ <a href=\"https://docs.codecov.com/docs/test-analytics\" rel=\"nofollow\">Test Analytics</a>: Detect flaky tests, report on failures, and find test suite problems.</li>\n</ul>\n</details>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948479356",
"body": " I'm going to raise errors if you attempt to convert to STRICT with a SQLite version that fails the `db.supports_strict` test.",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948479356,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:31:51Z",
"updated_at": "2026-07-11T19:31:51Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">I'm going to raise errors if you attempt to convert to STRICT with a SQLite version that fails the <code class=\"notranslate\">db.supports_strict</code> test.</p>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948493701",
"body": "We don't have any mechanism to support `ANY` columns at the moment. Open question how to deal with that. Options include:\r\n\r\n- Ignore the problem entirely\r\n- Add a `sqlite_utils.ANY` constant which can be used in create table calls, e.g. `db.create_table(\"t\", {\"id\": int, \"name\": str, \"misc\": sqlite_utils.ANY})` - would have to be handled in `add_column()` and `transform()` and a bunch of other places too.\r\n- Don't support them in create_table/etc but DO support them in introspection, since that's part of how `transform()` works",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948493701,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:36:01Z",
"updated_at": "2026-07-11T19:36:01Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">We don't have any mechanism to support <code class=\"notranslate\">ANY</code> columns at the moment. Open question how to deal with that. Options include:</p>\n<ul dir=\"auto\">\n<li>Ignore the problem entirely</li>\n<li>Add a <code class=\"notranslate\">sqlite_utils.ANY</code> constant which can be used in create table calls, e.g. <code class=\"notranslate\">db.create_table(\"t\", {\"id\": int, \"name\": str, \"misc\": sqlite_utils.ANY})</code> - would have to be handled in <code class=\"notranslate\">add_column()</code> and <code class=\"notranslate\">transform()</code> and a bunch of other places too.</li>\n<li>Don't support them in create_table/etc but DO support them in introspection, since that's part of how <code class=\"notranslate\">transform()</code> works</li>\n</ul>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948505824",
"body": "Here's a reproduction of the problem where the default `strict` value for a table is not correctly updated:\r\n```python\r\nfrom sqlite_utils import Database\r\n\r\ndb = Database(memory=True)\r\n\r\ntable = db.table(\"items\", strict=True)\r\ntable.create({\"id\": int})\r\n\r\ntable.transform(strict=False)\r\nassert table.strict is False\r\n\r\n# Recreate using the same Table object's stale strict=True default:\r\ntable.create({\"id\": int}, replace=True)\r\n\r\nassert table.strict is True # Unexpectedly strict again\r\n```",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948505824,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:40:09Z",
"updated_at": "2026-07-11T19:40:09Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">Here's a reproduction of the problem where the default <code class=\"notranslate\">strict</code> value for a table is not correctly updated:</p>\n<div class=\"highlight highlight-source-python notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"from sqlite_utils import Database\n\ndb = Database(memory=True)\n\ntable = db.table("items", strict=True)\ntable.create({"id": int})\n\ntable.transform(strict=False)\nassert table.strict is False\n\n# Recreate using the same Table object's stale strict=True default:\ntable.create({"id": int}, replace=True)\n\nassert table.strict is True # Unexpectedly strict again\"><pre class=\"notranslate\"><span class=\"pl-k\">from</span> <span class=\"pl-s1\">sqlite_utils</span> <span class=\"pl-k\">import</span> <span class=\"pl-v\">Database</span>\n\n<span class=\"pl-s1\">db</span> <span class=\"pl-c1\">=</span> <span class=\"pl-en\">Database</span>(<span class=\"pl-s1\">memory</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n\n<span class=\"pl-s1\">table</span> <span class=\"pl-c1\">=</span> <span class=\"pl-s1\">db</span>.<span class=\"pl-c1\">table</span>(<span class=\"pl-s\">\"items\"</span>, <span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n<span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">create</span>({<span class=\"pl-s\">\"id\"</span>: <span class=\"pl-s1\">int</span>})\n\n<span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">transform</span>(<span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">False</span>)\n<span class=\"pl-k\">assert</span> <span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">strict</span> <span class=\"pl-c1\">is</span> <span class=\"pl-c1\">False</span>\n\n<span class=\"pl-c\"># Recreate using the same Table object's stale strict=True default:</span>\n<span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">create</span>({<span class=\"pl-s\">\"id\"</span>: <span class=\"pl-s1\">int</span>}, <span class=\"pl-s1\">replace</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n\n<span class=\"pl-k\">assert</span> <span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">strict</span> <span class=\"pl-c1\">is</span> <span class=\"pl-c1\">True</span> <span class=\"pl-c\"># Unexpectedly strict again</span></pre></div>"
}
],
"created_at": "2026-07-11T19:29:17Z",
"updated_at": "2026-07-11T23:37:11Z",
"closed_at": null,
"merged_at": null,
"commits": 5,
"changed_files": 8,
"additions": 171,
"deletions": 8,
"display_url": "https://github.com/simonw/sqlite-utils/pull/788",
"display_title": ".transform(strict=) and sqlite-utils transform --strict/--no-strict"
},
"url": "https://github.com/simonw/sqlite-utils/pull/788",
"title": ".transform(strict=) and sqlite-utils transform --strict/--no-strict",
"diff": "@@ -9,6 +9,8 @@\n Unreleased\n ----------\n \n+- ``table.transform()`` and ``table.transform_sql()`` now accept ``strict=True`` or ``strict=False`` to change a table's SQLite strict mode. Omitting the option, or passing ``strict=None``, preserves the existing mode. (:issue:`787`)\n+- The ``sqlite-utils transform`` command now accepts ``--strict`` and ``--no-strict`` to change a table's SQLite strict mode. Omitting both options preserves the existing mode. (:issue:`787`)\n - ``sqlite-utils query`` can now read the SQL query from standard input by passing ``-`` in place of the query, for example ``echo \"select * from dogs\" | sqlite-utils query dogs.db -``. (:issue:`765`)\n - ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for :ref:`providing a block of Python code <cli_insert_code>` (or a path to a ``.py`` file) that defines a ``rows()`` function or ``rows`` iterable of rows to insert, as an alternative to importing from a file. (:issue:`684`)\n - ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept ``--type column-name type`` to :ref:`override the type automatically chosen when the table is created <cli_insert_csv_tsv_column_types>`. This is useful for CSV or TSV columns such as ZIP codes that look like integers but should be stored as ``TEXT`` to preserve leading zeros. (:issue:`131`)\n@@ -508,6 +508,8 @@ See :ref:`cli_transform_table`.\n Add a foreign key constraint from a column to\n another table with another column\n --drop-foreign-key TEXT Drop foreign key constraint for this column\n+ --strict / --no-strict Enable or disable STRICT mode (default:\n+ preserve current mode)\n --sql Output SQL without executing it\n --load-extension TEXT Path to SQLite extension, with optional\n :entrypoint\n@@ -2182,7 +2182,7 @@ Use ``--ignore`` to ignore the error if the table does not exist.\n Transforming tables\n ===================\n \n-The ``transform`` command allows you to apply complex transformations to a table that cannot be implemented using a regular SQLite ``ALTER TABLE`` command. See :ref:`python_api_transform` for details of how this works. The ``transform`` command preserves a table's ``STRICT`` mode.\n+The ``transform`` command allows you to apply complex transformations to a table that cannot be implemented using a regular SQLite ``ALTER TABLE`` command. See :ref:`python_api_transform` for details of how this works. By default, the ``transform`` command preserves a table's ``STRICT`` mode.\n \n .. code-block:: bash\n \n@@ -2228,6 +2228,12 @@ Every option for this table (with the exception of ``--pk-none``) can be specifi\n ``--add-foreign-key column other_table other_column``\n Add a foreign key constraint to ``column`` pointing to ``other_table.other_column``.\n \n+``--strict``\n+ Convert the table to a `SQLite STRICT table <https://www.sqlite.org/stricttables.html>`__. The command fails if the available SQLite version does not support strict tables. If existing rows contain values that are incompatible with their declared column types the transformation fails and the original table is left unchanged.\n+\n+``--no-strict``\n+ Convert a strict table back to a regular non-strict table.\n+\n If you want to see the SQL that will be executed to make the change without actually executing it, add the ``--sql`` flag. For example:\n \n .. code-block:: bash\n@@ -1753,6 +1753,29 @@ To alter the type of a column, use the ``types=`` argument:\n \n See :ref:`python_api_add_column` for a list of available types.\n \n+.. _python_api_transform_strict:\n+\n+Changing strict mode\n+--------------------\n+\n+The optional ``strict=`` parameter can change whether a table uses `SQLite STRICT mode <https://www.sqlite.org/stricttables.html>`__. Pass ``strict=True`` to convert a regular table to a strict table:\n+\n+.. code-block:: python\n+\n+ table.transform(strict=True)\n+\n+Pass ``strict=False`` to convert a strict table back to a regular non-strict table:\n+\n+.. code-block:: python\n+\n+ table.transform(strict=False)\n+\n+The default is ``strict=None``, which preserves the table's existing strict mode.\n+\n+Passing ``strict=True`` raises ``sqlite_utils.db.TransformError`` if the available SQLite version does not support strict tables.\n+\n+Converting to a strict table validates all existing rows as they are copied into the replacement table. If a value is incompatible with its declared column type, SQLite raises ``sqlite3.IntegrityError`` and the transformation is rolled back, leaving the original table and its data unchanged.\n+\n .. _python_api_transform_rename_columns:\n \n Renaming columns\n@@ -2718,6 +2718,11 @@ def schema(\n multiple=True,\n help=\"Drop foreign key constraint for this column\",\n )\n+@click.option(\n+ \"--strict/--no-strict\",\n+ default=None,\n+ help=\"Enable or disable STRICT mode (default: preserve current mode)\",\n+)\n @click.option(\"--sql\", is_flag=True, help=\"Output SQL without executing it\")\n @load_extension_option\n def transform(\n@@ -2735,6 +2740,7 @@ def transform(\n default_none,\n add_foreign_keys,\n drop_foreign_keys,\n+ strict,\n sql,\n load_extension,\n ):\n@@ -2796,6 +2802,7 @@ def transform(\n defaults=default_dict,\n drop_foreign_keys=drop_foreign_keys_value,\n add_foreign_keys=add_foreign_keys_value,\n+ strict=strict,\n ):\n click.echo(line)\n else:\n@@ -2809,6 +2816,7 @@ def transform(\n defaults=default_dict,\n drop_foreign_keys=drop_foreign_keys_value,\n add_foreign_keys=add_foreign_keys_value,\n+ strict=strict,\n )\n \n \n@@ -2514,6 +2514,7 @@ def transform(\n foreign_keys: Optional[ForeignKeysType] = None,\n column_order: Optional[List[str]] = None,\n keep_table: Optional[str] = None,\n+ strict: Optional[bool] = None,\n ) -> \"Table\":\n \"\"\"\n Apply an advanced alter table, including operations that are not supported by\n@@ -2536,6 +2537,8 @@ def transform(\n to use when creating the table\n :param keep_table: If specified, the existing table will be renamed to this and will not be\n dropped\n+ :param strict: Set to ``True`` to make the table strict or ``False`` to make it\n+ non-strict. Defaults to ``None``, which preserves the existing strict mode.\n \"\"\"\n if not self.exists():\n raise ValueError(\"Cannot transform a table that doesn't exist yet\")\n@@ -2551,6 +2554,7 @@ def transform(\n foreign_keys=foreign_keys,\n column_order=column_order,\n keep_table=keep_table,\n+ strict=strict,\n )\n pragma_foreign_keys_was_on = bool(\n self.db.execute(\"PRAGMA foreign_keys\").fetchone()[0]\n@@ -2587,6 +2591,8 @@ def transform(\n self.db.execute(\"PRAGMA defer_foreign_keys=OFF;\")\n if should_disable_foreign_keys:\n self.db.execute(\"PRAGMA foreign_keys=1;\")\n+ if strict is not None:\n+ self._defaults[\"strict\"] = strict\n return self\n \n def transform_sql(\n@@ -2604,6 +2610,7 @@ def transform_sql(\n column_order: Optional[List[str]] = None,\n tmp_suffix: Optional[str] = None,\n keep_table: Optional[str] = None,\n+ strict: Optional[bool] = None,\n ) -> List[str]:\n \"\"\"\n Return a list of SQL statements that should be executed in order to apply this transformation.\n@@ -2624,7 +2631,11 @@ def transform_sql(\n :param tmp_suffix: Suffix to use for the temporary table name\n :param keep_table: If specified, the existing table will be renamed to this and will not be\n dropped\n+ :param strict: Set to ``True`` to make the table strict or ``False`` to make it\n+ non-strict. Defaults to ``None``, which preserves the existing strict mode.\n \"\"\"\n+ if strict is True and not self.db.supports_strict:\n+ raise TransformError(\"SQLite does not support STRICT tables\")\n types = types or {}\n rename = rename or {}\n drop = drop or set()\n@@ -2806,7 +2817,7 @@ def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:\n defaults=create_table_defaults,\n foreign_keys=create_table_foreign_keys,\n column_order=column_order,\n- strict=self.strict,\n+ strict=self.strict if strict is None else strict,\n ).strip()\n )\n \n@@ -3,6 +3,7 @@\n from click.testing import CliRunner\n from pathlib import Path\n import subprocess\n+import sqlite3\n import sys\n import json\n import os\n@@ -1939,6 +1940,64 @@ def test_transform_sql(db_path):\n assert db[\"dogs\"].schema == original_schema\n \n \n+@pytest.mark.parametrize(\n+ \"initial_strict,args,expected_strict\",\n+ (\n+ (False, [], False),\n+ (True, [], True),\n+ (False, [\"--strict\"], True),\n+ (True, [\"--no-strict\"], False),\n+ ),\n+)\n+def test_transform_strict_option(db_path, initial_strict, args, expected_strict):\n+ db = Database(db_path)\n+ if not db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ db[\"dogs\"].create({\"id\": int}, strict=initial_strict)\n+\n+ result = CliRunner().invoke(cli.cli, [\"transform\", db_path, \"dogs\"] + args)\n+\n+ assert result.exit_code == 0, result.output\n+ assert db[\"dogs\"].strict is expected_strict\n+\n+\n+@pytest.mark.parametrize(\n+ \"initial_strict,flag,sql_is_strict\",\n+ (\n+ (False, \"--strict\", True),\n+ (True, \"--no-strict\", False),\n+ ),\n+)\n+def test_transform_strict_option_sql(db_path, initial_strict, flag, sql_is_strict):\n+ db = Database(db_path)\n+ if not db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ db[\"dogs\"].create({\"id\": int}, strict=initial_strict)\n+\n+ result = CliRunner().invoke(cli.cli, [\"transform\", db_path, \"dogs\", flag, \"--sql\"])\n+\n+ assert result.exit_code == 0, result.output\n+ assert (\") STRICT;\" in result.output) is sql_is_strict\n+ assert db[\"dogs\"].strict is initial_strict\n+\n+\n+def test_transform_strict_option_with_invalid_data(db_path):\n+ db = Database(db_path)\n+ if not db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ dogs = db[\"dogs\"]\n+ dogs.create({\"id\": int})\n+ dogs.insert({\"id\": \"not-an-integer\"})\n+\n+ result = CliRunner().invoke(cli.cli, [\"transform\", db_path, \"dogs\", \"--strict\"])\n+\n+ assert result.exit_code == 1\n+ assert isinstance(result.exception, sqlite3.IntegrityError)\n+ assert dogs.strict is False\n+ assert list(dogs.rows) == [{\"id\": \"not-an-integer\"}]\n+ assert not any(name.startswith(\"dogs_new_\") for name in db.table_names())\n+\n+\n @pytest.mark.parametrize(\n \"extra_args,expected_schema\",\n (\n@@ -1,3 +1,5 @@\n+import sqlite3\n+\n from sqlite_utils.db import ForeignKey, TransformError\n from sqlite_utils.utils import OperationalError\n import pytest\n@@ -566,13 +568,63 @@ def test_transform_preserves_rowids(fresh_db, table_type):\n assert previous_rows == next_rows\n \n \n-@pytest.mark.parametrize(\"strict\", (False, True))\n-def test_transform_strict(fresh_db, strict):\n- dogs = fresh_db.table(\"dogs\", strict=strict)\n+@pytest.mark.parametrize(\n+ \"initial_strict,transform_strict,expected_strict\",\n+ (\n+ (False, None, False),\n+ (True, None, True),\n+ (False, True, True),\n+ (True, False, False),\n+ ),\n+)\n+def test_transform_strict(fresh_db, initial_strict, transform_strict, expected_strict):\n+ if not fresh_db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ dogs = fresh_db.table(\"dogs\", strict=initial_strict)\n dogs.insert({\"id\": 1, \"name\": \"Cleo\"})\n- assert dogs.strict == strict or not fresh_db.supports_strict\n- dogs.transform(not_null={\"name\"})\n- assert dogs.strict == strict or not fresh_db.supports_strict\n+ assert dogs.strict is initial_strict\n+ dogs.transform(strict=transform_strict)\n+ assert dogs.strict is expected_strict\n+\n+\n+def test_transform_to_strict_with_invalid_data(fresh_db):\n+ if not fresh_db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ dogs = fresh_db[\"dogs\"]\n+ dogs.create({\"id\": int})\n+ dogs.insert({\"id\": \"not-an-integer\"})\n+\n+ with pytest.raises(sqlite3.IntegrityError):\n+ dogs.transform(strict=True)\n+\n+ assert dogs.strict is False\n+ assert list(dogs.rows) == [{\"id\": \"not-an-integer\"}]\n+ assert fresh_db.table_names() == [\"dogs\"]\n+\n+\n+def test_transform_strict_updates_default(fresh_db):\n+ if not fresh_db.supports_strict:\n+ pytest.skip(\"SQLite version does not support strict tables\")\n+ table = fresh_db.table(\"items\", strict=True)\n+ table.create({\"id\": int})\n+\n+ table.transform(strict=False)\n+ assert table.strict is False\n+\n+ table.create({\"id\": int}, replace=True)\n+ assert table.strict is False\n+\n+\n+@pytest.mark.parametrize(\"method_name\", (\"transform\", \"transform_sql\"))\n+def test_transform_to_strict_not_supported(fresh_db, method_name):\n+ table = fresh_db[\"items\"]\n+ table.create({\"id\": int})\n+ fresh_db._supports_strict = False\n+\n+ with pytest.raises(TransformError, match=\"SQLite does not support STRICT tables\"):\n+ getattr(table, method_name)(strict=True)\n+\n+ assert table.strict is False\n \n \n @pytest.mark.parametrize(",
"comments": [
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948473657",
"body": "> use `uv run python -c` and manually exercise the new `.transform(strict=`) option, see if you can find any edge-cases or bugs\r\n\r\nGPT-5.6 Sol then found some edge-cases:\r\n\r\n> Manual probing found two bugs directly relevant to the new option.\r\n> \r\n> 1. Explicit `strict=True` silently fails on unsupported SQLite\r\n> \r\n> Simulating an older SQLite version produced:\r\n> \r\n> ```python\r\n> t.transform(strict=True)\r\n> t.strict # False\r\n> ```\r\n> \r\n> `create_table_sql()` only appends `STRICT` when `db.supports_strict` is true ([db.py](/Users/simon/Dropbox/dev/sqlite-utils/sqlite_utils/db.py:1554)). An explicit conversion should probably raise an informative error instead of succeeding as a no-op.\r\n> \r\n> 2. The `Table` object retains stale strict defaults\r\n> \r\n> After:\r\n> \r\n> ```python\r\n> t = db.table(\"items\", strict=True)\r\n> t.create({\"id\": int})\r\n> t.transform(strict=False)\r\n> ```\r\n> \r\n> The schema becomes non-strict, but `t._defaults[\"strict\"]` remains `True`. Dropping and recreating through the same object unexpectedly creates another strict table. The reverse happens when converting from non-strict to strict.\r\n> \r\n> The default should be updated after a successful transformation, including storing `False`. It should remain unchanged if the transformation rolls back.\r\n> \r\n> Other notable edge cases:\r\n> \r\n> - SQLite `ANY` columns are introspected as `float`, so transforming a native strict table containing `ANY` can fail or change it to `REAL`. This is an existing transform/introspection limitation, but applies to `strict=None` and `strict=True`.\r\n> - An incompatible declared default does not prevent conversion. An empty `INTEGER DEFAULT 'not-an-integer'` table becomes strict successfully, but later inserting the default raises `IntegrityError`. This is SQLite behavior.\r\n> - Triggers are dropped during transformation. This is existing documented behavior, not introduced by this change.\r\n> - Losslessly convertible values work: `\"123\"` and `\"123.0\"` become integer `123`.\r\n> - Fractional numbers, incompatible text, and BLOBs correctly fail and roll back.\r\n> - Indexes and foreign keys were preserved.\r\n> - Failed conversion inside an existing `db.atomic()` transaction rolled back only the transform; the outer transaction remained usable.\r\n> - `keep_table` correctly retained the original non-strict table alongside the new strict table.\r\n> - Repeated `None → True → None → False → None` transitions behaved correctly.\r\n> \r\n> No files were changed during this diagnostic pass.",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948473657,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:30:03Z",
"updated_at": "2026-07-11T19:30:03Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<blockquote>\n<p dir=\"auto\">use <code class=\"notranslate\">uv run python -c</code> and manually exercise the new <code class=\"notranslate\">.transform(strict=</code>) option, see if you can find any edge-cases or bugs</p>\n</blockquote>\n<p dir=\"auto\">GPT-5.6 Sol then found some edge-cases:</p>\n<blockquote>\n<p dir=\"auto\">Manual probing found two bugs directly relevant to the new option.</p>\n<ol dir=\"auto\">\n<li>Explicit <code class=\"notranslate\">strict=True</code> silently fails on unsupported SQLite</li>\n</ol>\n<p dir=\"auto\">Simulating an older SQLite version produced:</p>\n<div class=\"highlight highlight-source-python notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"t.transform(strict=True)\nt.strict # False\"><pre class=\"notranslate\"><span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">transform</span>(<span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n<span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">strict</span> <span class=\"pl-c\"># False</span></pre></div>\n<p dir=\"auto\"><code class=\"notranslate\">create_table_sql()</code> only appends <code class=\"notranslate\">STRICT</code> when <code class=\"notranslate\">db.supports_strict</code> is true (<a href=\"/Users/simon/Dropbox/dev/sqlite-utils/sqlite_utils/db.py:1554\">db.py</a>). An explicit conversion should probably raise an informative error instead of succeeding as a no-op.</p>\n<ol start=\"2\" dir=\"auto\">\n<li>The <code class=\"notranslate\">Table</code> object retains stale strict defaults</li>\n</ol>\n<p dir=\"auto\">After:</p>\n<div class=\"highlight highlight-source-python notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"t = db.table("items", strict=True)\nt.create({"id": int})\nt.transform(strict=False)\"><pre class=\"notranslate\"><span class=\"pl-s1\">t</span> <span class=\"pl-c1\">=</span> <span class=\"pl-s1\">db</span>.<span class=\"pl-c1\">table</span>(<span class=\"pl-s\">\"items\"</span>, <span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n<span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">create</span>({<span class=\"pl-s\">\"id\"</span>: <span class=\"pl-s1\">int</span>})\n<span class=\"pl-s1\">t</span>.<span class=\"pl-c1\">transform</span>(<span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">False</span>)</pre></div>\n<p dir=\"auto\">The schema becomes non-strict, but <code class=\"notranslate\">t._defaults[\"strict\"]</code> remains <code class=\"notranslate\">True</code>. Dropping and recreating through the same object unexpectedly creates another strict table. The reverse happens when converting from non-strict to strict.</p>\n<p dir=\"auto\">The default should be updated after a successful transformation, including storing <code class=\"notranslate\">False</code>. It should remain unchanged if the transformation rolls back.</p>\n<p dir=\"auto\">Other notable edge cases:</p>\n<ul dir=\"auto\">\n<li>SQLite <code class=\"notranslate\">ANY</code> columns are introspected as <code class=\"notranslate\">float</code>, so transforming a native strict table containing <code class=\"notranslate\">ANY</code> can fail or change it to <code class=\"notranslate\">REAL</code>. This is an existing transform/introspection limitation, but applies to <code class=\"notranslate\">strict=None</code> and <code class=\"notranslate\">strict=True</code>.</li>\n<li>An incompatible declared default does not prevent conversion. An empty <code class=\"notranslate\">INTEGER DEFAULT 'not-an-integer'</code> table becomes strict successfully, but later inserting the default raises <code class=\"notranslate\">IntegrityError</code>. This is SQLite behavior.</li>\n<li>Triggers are dropped during transformation. This is existing documented behavior, not introduced by this change.</li>\n<li>Losslessly convertible values work: <code class=\"notranslate\">\"123\"</code> and <code class=\"notranslate\">\"123.0\"</code> become integer <code class=\"notranslate\">123</code>.</li>\n<li>Fractional numbers, incompatible text, and BLOBs correctly fail and roll back.</li>\n<li>Indexes and foreign keys were preserved.</li>\n<li>Failed conversion inside an existing <code class=\"notranslate\">db.atomic()</code> transaction rolled back only the transform; the outer transaction remained usable.</li>\n<li><code class=\"notranslate\">keep_table</code> correctly retained the original non-strict table alongside the new strict table.</li>\n<li>Repeated <code class=\"notranslate\">None → True → None → False → None</code> transitions behaved correctly.</li>\n</ul>\n<p dir=\"auto\">No files were changed during this diagnostic pass.</p>\n</blockquote>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948475411",
"body": "## [Codecov](https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison) Report\n:white_check_mark: All modified and coverable lines are covered by tests.\n:white_check_mark: Project coverage is 95.54%. Comparing base ([`6531a57`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/6531a57863ce23d502e504fd8fcd375fbe5cbb7f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)) to head ([`5b9898e`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/5b9898ed9b5f71d45d85001543363cd48eadd646?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)).\n\n<details><summary>Additional details and impacted files</summary>\n\n\n\n```diff\n@@ Coverage Diff @@\n## main #788 +/- ##\n=======================================\n Coverage 95.54% 95.54% \n=======================================\n Files 9 9 \n Lines 3793 3796 +3 \n=======================================\n+ Hits 3624 3627 +3 \n Misses 169 169 \n```\n</details>\n\n[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison). \n:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison).\n<details><summary> :rocket: New features to boost your workflow: </summary>\n\n- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.\n</details>",
"user": {
"login": "codecov[bot]",
"name": "codecov[bot]",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/in/254?v=4",
"id": 22429695
},
"id": 4948475411,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:30:37Z",
"updated_at": "2026-07-11T23:33:48Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<h2 dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Codecov</a> Report</h2>\n<p dir=\"auto\">✅ All modified and coverable lines are covered by tests.<br>\n✅ Project coverage is 95.54%. Comparing base (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/6531a57863ce23d502e504fd8fcd375fbe5cbb7f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">6531a57</code></a>) to head (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/5b9898ed9b5f71d45d85001543363cd48eadd646?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">5b9898e</code></a>).</p>\n<details><summary>Additional details and impacted files</summary>\n<div class=\"highlight highlight-source-diff notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"@@ Coverage Diff @@\n## main #788 +/- ##\n=======================================\n Coverage 95.54% 95.54% \n=======================================\n Files 9 9 \n Lines 3793 3796 +3 \n=======================================\n+ Hits 3624 3627 +3 \n Misses 169 169 \"><pre class=\"notranslate\"><span class=\"pl-mdr\">@@ Coverage Diff @@</span>\n<span class=\"pl-c\"><span class=\"pl-c\">#</span># main #788 +/- ##</span>\n=======================================\n Coverage 95.54% 95.54% \n=======================================\n Files 9 9 \n Lines 3793 3796 +3 \n=======================================\n<span class=\"pl-mi1\"><span class=\"pl-mi1\">+</span> Hits 3624 3627 +3 </span>\n Misses 169 169 </pre></div>\n</details>\n<p dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/788?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">☔ View full report in Codecov by Harness</a>.<br>\n📢 Have feedback on the report? <a href=\"https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Share it here</a>.</p>\n<details><summary> 🚀 New features to boost your workflow: </summary>\n<ul dir=\"auto\">\n<li>❄️ <a href=\"https://docs.codecov.com/docs/test-analytics\" rel=\"nofollow\">Test Analytics</a>: Detect flaky tests, report on failures, and find test suite problems.</li>\n</ul>\n</details>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948479356",
"body": " I'm going to raise errors if you attempt to convert to STRICT with a SQLite version that fails the `db.supports_strict` test.",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948479356,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:31:51Z",
"updated_at": "2026-07-11T19:31:51Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">I'm going to raise errors if you attempt to convert to STRICT with a SQLite version that fails the <code class=\"notranslate\">db.supports_strict</code> test.</p>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948493701",
"body": "We don't have any mechanism to support `ANY` columns at the moment. Open question how to deal with that. Options include:\r\n\r\n- Ignore the problem entirely\r\n- Add a `sqlite_utils.ANY` constant which can be used in create table calls, e.g. `db.create_table(\"t\", {\"id\": int, \"name\": str, \"misc\": sqlite_utils.ANY})` - would have to be handled in `add_column()` and `transform()` and a bunch of other places too.\r\n- Don't support them in create_table/etc but DO support them in introspection, since that's part of how `transform()` works",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948493701,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:36:01Z",
"updated_at": "2026-07-11T19:36:01Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">We don't have any mechanism to support <code class=\"notranslate\">ANY</code> columns at the moment. Open question how to deal with that. Options include:</p>\n<ul dir=\"auto\">\n<li>Ignore the problem entirely</li>\n<li>Add a <code class=\"notranslate\">sqlite_utils.ANY</code> constant which can be used in create table calls, e.g. <code class=\"notranslate\">db.create_table(\"t\", {\"id\": int, \"name\": str, \"misc\": sqlite_utils.ANY})</code> - would have to be handled in <code class=\"notranslate\">add_column()</code> and <code class=\"notranslate\">transform()</code> and a bunch of other places too.</li>\n<li>Don't support them in create_table/etc but DO support them in introspection, since that's part of how <code class=\"notranslate\">transform()</code> works</li>\n</ul>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/788#issuecomment-4948505824",
"body": "Here's a reproduction of the problem where the default `strict` value for a table is not correctly updated:\r\n```python\r\nfrom sqlite_utils import Database\r\n\r\ndb = Database(memory=True)\r\n\r\ntable = db.table(\"items\", strict=True)\r\ntable.create({\"id\": int})\r\n\r\ntable.transform(strict=False)\r\nassert table.strict is False\r\n\r\n# Recreate using the same Table object's stale strict=True default:\r\ntable.create({\"id\": int}, replace=True)\r\n\r\nassert table.strict is True # Unexpectedly strict again\r\n```",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4948505824,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-11T19:40:09Z",
"updated_at": "2026-07-11T19:40:09Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">Here's a reproduction of the problem where the default <code class=\"notranslate\">strict</code> value for a table is not correctly updated:</p>\n<div class=\"highlight highlight-source-python notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"from sqlite_utils import Database\n\ndb = Database(memory=True)\n\ntable = db.table("items", strict=True)\ntable.create({"id": int})\n\ntable.transform(strict=False)\nassert table.strict is False\n\n# Recreate using the same Table object's stale strict=True default:\ntable.create({"id": int}, replace=True)\n\nassert table.strict is True # Unexpectedly strict again\"><pre class=\"notranslate\"><span class=\"pl-k\">from</span> <span class=\"pl-s1\">sqlite_utils</span> <span class=\"pl-k\">import</span> <span class=\"pl-v\">Database</span>\n\n<span class=\"pl-s1\">db</span> <span class=\"pl-c1\">=</span> <span class=\"pl-en\">Database</span>(<span class=\"pl-s1\">memory</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n\n<span class=\"pl-s1\">table</span> <span class=\"pl-c1\">=</span> <span class=\"pl-s1\">db</span>.<span class=\"pl-c1\">table</span>(<span class=\"pl-s\">\"items\"</span>, <span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n<span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">create</span>({<span class=\"pl-s\">\"id\"</span>: <span class=\"pl-s1\">int</span>})\n\n<span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">transform</span>(<span class=\"pl-s1\">strict</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">False</span>)\n<span class=\"pl-k\">assert</span> <span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">strict</span> <span class=\"pl-c1\">is</span> <span class=\"pl-c1\">False</span>\n\n<span class=\"pl-c\"># Recreate using the same Table object's stale strict=True default:</span>\n<span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">create</span>({<span class=\"pl-s\">\"id\"</span>: <span class=\"pl-s1\">int</span>}, <span class=\"pl-s1\">replace</span><span class=\"pl-c1\">=</span><span class=\"pl-c1\">True</span>)\n\n<span class=\"pl-k\">assert</span> <span class=\"pl-s1\">table</span>.<span class=\"pl-c1\">strict</span> <span class=\"pl-c1\">is</span> <span class=\"pl-c1\">True</span> <span class=\"pl-c\"># Unexpectedly strict again</span></pre></div>"
}
],
"display_url": "https://github.com/simonw/sqlite-utils/pull/788",
"display_title": ".transform(strict=) and sqlite-utils transform --strict/--no-strict"
}MCP tool call
codex_apps.github.fetch_pr
{
"repo_full_name": "simonw/sqlite-utils",
"pr_number": 784
}Action completed.
{
"pull_request": {
"url": "https://github.com/simonw/sqlite-utils/pull/784",
"number": 784,
"state": "open",
"merged": false,
"mergeable": true,
"draft": false,
"body": "Refs #147.\n\n`SQLITE_MAX_VARS` is hard-coded to 999, which caps how many rows `insert_all` batches into a single INSERT. Many SQLite builds are compiled with a much higher `SQLITE_MAX_VARIABLE_NUMBER` (e.g. 250,000 on Debian/Ubuntu, 500,000 on recent macOS), so on those systems the 999 cap forces many more, smaller batches than necessary.\n\nAs suggested in the issue, this adds an optional `sqlite_max_vars` argument to the `Database` constructor:\n\n```python\ndb = Database(\"data.db\", sqlite_max_vars=250_000)\n```\n\n- Default behaviour is unchanged: when the argument is not given, the limit falls back to the module-level `SQLITE_MAX_VARS` (999).\n- A `Database.sqlite_max_vars` property exposes the effective value.\n- `Table.insert_all` uses it in both places that previously referenced the module global: the column-count guard and the batch-size calculation.\n\nI deliberately kept this to the constructor argument only, and did not add automatic detection of the compiled limit — that would change default batching for everyone and is a larger, separate change.\n\nDocs updated in `docs/python-api.rst`; tests added in `tests/test_create.py` (default stays 999, a raised value produces fewer INSERT batches as measured via the `tracer` hook, and the column-count error message reflects the custom value).\n\r\n\r\n<!-- readthedocs-preview sqlite-utils start -->\r\n----\n📚 Documentation preview 📚: https://sqlite-utils--784.org.readthedocs.build/en/784/\n\r\n<!-- readthedocs-preview sqlite-utils end -->",
"title": "Allow SQLITE_MAX_VARS to be customized via Database(sqlite_max_vars=...)",
"base": "main",
"base_sha": "7a52214624ae0e2c3fdf07215c1bcfc1393dbd93",
"head": "feature/sqlite-max-vars-configurable",
"head_sha": "7c01b8d58831b94b6885d707cdab6cc168977364",
"merge_commit_sha": "7fb8e15617c1ea50969cbfba98f94817ddf638a2",
"user": {
"login": "AmadNaseem",
"name": "AmadNaseem",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/45733533?v=4",
"id": 45733533
},
"requested_reviewers": null,
"requested_team_reviewers": null,
"diff": "@@ -1026,6 +1026,12 @@ The function can accept an iterator or generator of rows and will commit them ac\n \"name\": \"Name {}\".format(i),\n } for i in range(10000)), batch_size=1000)\n \n+The largest batch that will actually be sent to SQLite is limited by the maximum number of SQL variables allowed in a single query, which defaults to 999. If your copy of SQLite was compiled with a higher ``SQLITE_MAX_VARIABLE_NUMBER`` you can tell ``sqlite-utils`` to use larger batches - and hence run faster - by passing ``sqlite_max_vars=`` to the ``Database()`` constructor:\n+\n+.. code-block:: python\n+\n+ db = Database(\"big.db\", sqlite_max_vars=100_000)\n+\n You can skip inserting any records that have a primary key that already exists using ``ignore=True``. This works with both ``.insert({...}, ignore=True)`` and ``.insert_all([...], ignore=True)``.\n \n You can delete all the existing rows in the table before inserting the new records using ``truncate=True``. This is useful if you want to replace the data in the table.\n@@ -504,6 +504,9 @@ class Database:\n :param use_old_upsert: set to ``True`` to force the older upsert implementation. See\n :ref:`python_api_old_upsert`\n :param strict: Apply STRICT mode to all created tables (unless overridden)\n+ :param sqlite_max_vars: Maximum number of SQL variables to use in a single query. Defaults\n+ to ``sqlite_utils.db.SQLITE_MAX_VARS`` (999). Increase this if your SQLite was compiled\n+ with a higher ``SQLITE_MAX_VARIABLE_NUMBER`` to allow larger insert batches\n \"\"\"\n \n _counts_table_name = \"_counts\"\n@@ -522,10 +525,12 @@ def __init__(\n execute_plugins: bool = True,\n use_old_upsert: bool = False,\n strict: bool = False,\n+ sqlite_max_vars: Optional[int] = None,\n ):\n self.memory_name = None\n self.memory = False\n self.use_old_upsert = use_old_upsert\n+ self._sqlite_max_vars = sqlite_max_vars\n if not (\n (filename_or_conn is not None and (not memory and not memory_name))\n or (filename_or_conn is None and (memory or memory_name))\n@@ -579,6 +584,17 @@ def __init__(\n pm.hook.prepare_connection(conn=self.conn)\n self.strict = strict\n \n+ @property\n+ def sqlite_max_vars(self) -> int:\n+ \"\"\"\n+ The maximum number of SQL variables to use in a single query. This is the value\n+ passed as ``sqlite_max_vars=`` to the constructor, or the\n+ ``sqlite_utils.db.SQLITE_MAX_VARS`` default of 999 if that was not set.\n+ \"\"\"\n+ if self._sqlite_max_vars is not None:\n+ return self._sqlite_max_vars\n+ return SQLITE_MAX_VARS\n+\n def __enter__(self) -> \"Database\":\n return self\n \n@@ -4436,14 +4452,11 @@ def insert_all(\n first_record = cast(Dict[str, Any], first_record)\n num_columns = len(first_record.keys())\n \n- if num_columns > SQLITE_MAX_VARS:\n- raise ValueError(\n- \"Rows can have a maximum of {} columns\".format(SQLITE_MAX_VARS)\n- )\n+ max_vars = self.db.sqlite_max_vars\n+ if num_columns > max_vars:\n+ raise ValueError(\"Rows can have a maximum of {} columns\".format(max_vars))\n batch_size = (\n- 1\n- if num_columns == 0\n- else max(1, min(batch_size, SQLITE_MAX_VARS // num_columns))\n+ 1 if num_columns == 0 else max(1, min(batch_size, max_vars // num_columns))\n )\n self.last_rowid = None\n self.last_pk = None\n@@ -695,6 +695,39 @@ def test_bulk_insert_more_than_999_values(fresh_db):\n assert fresh_db[\"big\"].count == 100\n \n \n+def test_sqlite_max_vars_defaults_to_999():\n+ # https://github.com/simonw/sqlite-utils/issues/147\n+ assert Database(memory=True).sqlite_max_vars == 999\n+\n+\n+def test_sqlite_max_vars_can_be_customized():\n+ # https://github.com/simonw/sqlite-utils/issues/147\n+ assert Database(memory=True, sqlite_max_vars=100000).sqlite_max_vars == 100000\n+ # A raised limit should allow a bigger batch, so the same records are\n+ # written using fewer INSERT statements\n+ records = [{\"c{}\".format(i): i for i in range(5)} for _ in range(500)]\n+\n+ def count_inserts(sqlite_max_vars):\n+ seen = []\n+ db = Database(\n+ memory=True,\n+ sqlite_max_vars=sqlite_max_vars,\n+ tracer=lambda sql, params: seen.append(sql),\n+ )\n+ db[\"t\"].insert_all(records, batch_size=100000)\n+ return len([sql for sql in seen if sql.strip().upper().startswith(\"INSERT\")])\n+\n+ assert count_inserts(100000) == 1\n+ assert count_inserts(None) > 1\n+\n+\n+def test_error_message_uses_custom_sqlite_max_vars():\n+ # https://github.com/simonw/sqlite-utils/issues/147\n+ db = Database(memory=True, sqlite_max_vars=10)\n+ with pytest.raises(ValueError, match=\"maximum of 10 columns\"):\n+ db[\"big\"].insert({\"c{}\".format(i): i for i in range(11)})\n+\n+\n @pytest.mark.parametrize(\n \"num_columns,should_error\", ((900, False), (999, False), (1000, True))\n )",
"comments": [],
"created_at": "2026-07-08T08:53:44Z",
"updated_at": "2026-07-08T08:53:56Z",
"closed_at": null,
"merged_at": null,
"commits": 1,
"changed_files": 3,
"additions": 59,
"deletions": 7,
"display_url": "https://github.com/simonw/sqlite-utils/pull/784",
"display_title": "Allow SQLITE_MAX_VARS to be customized via Database(sqlite_max_vars=...)"
},
"url": "https://github.com/simonw/sqlite-utils/pull/784",
"title": "Allow SQLITE_MAX_VARS to be customized via Database(sqlite_max_vars=...)",
"diff": "@@ -1026,6 +1026,12 @@ The function can accept an iterator or generator of rows and will commit them ac\n \"name\": \"Name {}\".format(i),\n } for i in range(10000)), batch_size=1000)\n \n+The largest batch that will actually be sent to SQLite is limited by the maximum number of SQL variables allowed in a single query, which defaults to 999. If your copy of SQLite was compiled with a higher ``SQLITE_MAX_VARIABLE_NUMBER`` you can tell ``sqlite-utils`` to use larger batches - and hence run faster - by passing ``sqlite_max_vars=`` to the ``Database()`` constructor:\n+\n+.. code-block:: python\n+\n+ db = Database(\"big.db\", sqlite_max_vars=100_000)\n+\n You can skip inserting any records that have a primary key that already exists using ``ignore=True``. This works with both ``.insert({...}, ignore=True)`` and ``.insert_all([...], ignore=True)``.\n \n You can delete all the existing rows in the table before inserting the new records using ``truncate=True``. This is useful if you want to replace the data in the table.\n@@ -504,6 +504,9 @@ class Database:\n :param use_old_upsert: set to ``True`` to force the older upsert implementation. See\n :ref:`python_api_old_upsert`\n :param strict: Apply STRICT mode to all created tables (unless overridden)\n+ :param sqlite_max_vars: Maximum number of SQL variables to use in a single query. Defaults\n+ to ``sqlite_utils.db.SQLITE_MAX_VARS`` (999). Increase this if your SQLite was compiled\n+ with a higher ``SQLITE_MAX_VARIABLE_NUMBER`` to allow larger insert batches\n \"\"\"\n \n _counts_table_name = \"_counts\"\n@@ -522,10 +525,12 @@ def __init__(\n execute_plugins: bool = True,\n use_old_upsert: bool = False,\n strict: bool = False,\n+ sqlite_max_vars: Optional[int] = None,\n ):\n self.memory_name = None\n self.memory = False\n self.use_old_upsert = use_old_upsert\n+ self._sqlite_max_vars = sqlite_max_vars\n if not (\n (filename_or_conn is not None and (not memory and not memory_name))\n or (filename_or_conn is None and (memory or memory_name))\n@@ -579,6 +584,17 @@ def __init__(\n pm.hook.prepare_connection(conn=self.conn)\n self.strict = strict\n \n+ @property\n+ def sqlite_max_vars(self) -> int:\n+ \"\"\"\n+ The maximum number of SQL variables to use in a single query. This is the value\n+ passed as ``sqlite_max_vars=`` to the constructor, or the\n+ ``sqlite_utils.db.SQLITE_MAX_VARS`` default of 999 if that was not set.\n+ \"\"\"\n+ if self._sqlite_max_vars is not None:\n+ return self._sqlite_max_vars\n+ return SQLITE_MAX_VARS\n+\n def __enter__(self) -> \"Database\":\n return self\n \n@@ -4436,14 +4452,11 @@ def insert_all(\n first_record = cast(Dict[str, Any], first_record)\n num_columns = len(first_record.keys())\n \n- if num_columns > SQLITE_MAX_VARS:\n- raise ValueError(\n- \"Rows can have a maximum of {} columns\".format(SQLITE_MAX_VARS)\n- )\n+ max_vars = self.db.sqlite_max_vars\n+ if num_columns > max_vars:\n+ raise ValueError(\"Rows can have a maximum of {} columns\".format(max_vars))\n batch_size = (\n- 1\n- if num_columns == 0\n- else max(1, min(batch_size, SQLITE_MAX_VARS // num_columns))\n+ 1 if num_columns == 0 else max(1, min(batch_size, max_vars // num_columns))\n )\n self.last_rowid = None\n self.last_pk = None\n@@ -695,6 +695,39 @@ def test_bulk_insert_more_than_999_values(fresh_db):\n assert fresh_db[\"big\"].count == 100\n \n \n+def test_sqlite_max_vars_defaults_to_999():\n+ # https://github.com/simonw/sqlite-utils/issues/147\n+ assert Database(memory=True).sqlite_max_vars == 999\n+\n+\n+def test_sqlite_max_vars_can_be_customized():\n+ # https://github.com/simonw/sqlite-utils/issues/147\n+ assert Database(memory=True, sqlite_max_vars=100000).sqlite_max_vars == 100000\n+ # A raised limit should allow a bigger batch, so the same records are\n+ # written using fewer INSERT statements\n+ records = [{\"c{}\".format(i): i for i in range(5)} for _ in range(500)]\n+\n+ def count_inserts(sqlite_max_vars):\n+ seen = []\n+ db = Database(\n+ memory=True,\n+ sqlite_max_vars=sqlite_max_vars,\n+ tracer=lambda sql, params: seen.append(sql),\n+ )\n+ db[\"t\"].insert_all(records, batch_size=100000)\n+ return len([sql for sql in seen if sql.strip().upper().startswith(\"INSERT\")])\n+\n+ assert count_inserts(100000) == 1\n+ assert count_inserts(None) > 1\n+\n+\n+def test_error_message_uses_custom_sqlite_max_vars():\n+ # https://github.com/simonw/sqlite-utils/issues/147\n+ db = Database(memory=True, sqlite_max_vars=10)\n+ with pytest.raises(ValueError, match=\"maximum of 10 columns\"):\n+ db[\"big\"].insert({\"c{}\".format(i): i for i in range(11)})\n+\n+\n @pytest.mark.parametrize(\n \"num_columns,should_error\", ((900, False), (999, False), (1000, True))\n )",
"comments": null,
"display_url": "https://github.com/simonw/sqlite-utils/pull/784",
"display_title": "Allow SQLITE_MAX_VARS to be customized via Database(sqlite_max_vars=...)"
}MCP tool call
codex_apps.github.fetch_pr
{
"repo_full_name": "simonw/sqlite-utils",
"pr_number": 776
}Action completed.
{
"pull_request": {
"url": "https://github.com/simonw/sqlite-utils/pull/776",
"number": 776,
"state": "closed",
"merged": true,
"mergeable": false,
"draft": false,
"body": "Closes #705.\n\n`Database.ensure_autocommit_off()` set `isolation_level = None` on the underlying `sqlite3` connection, which per the Python docs puts the driver *into* autocommit mode — the opposite of what the name and docstring claimed. The behavior itself was correct for its call sites (running `PRAGMA journal_mode=...` outside implicit transactions), so this renames the method to `ensure_autocommit_on()` and rewrites the docstring to describe what it actually does.\n\nThis is a breaking change for 4.0 — the old name is removed with no deprecated alias. Documented in the changelog (Unreleased → Breaking changes) and in the 3.x → 4.0 upgrade guide.\n\n- Renamed method and updated its three internal call sites in `sqlite_utils/db.py`\n- Added `test_ensure_autocommit_on` verifying `isolation_level` is `None` inside the block and restored afterwards\n- Full test suite passes: 1200 passed, 16 skipped\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\r\n\r\n<!-- readthedocs-preview sqlite-utils start -->\r\n----\n📚 Documentation preview 📚: https://sqlite-utils--776.org.readthedocs.build/en/776/\n\r\n<!-- readthedocs-preview sqlite-utils end -->",
"title": "Rename ensure_autocommit_off() to ensure_autocommit_on()",
"base": "main",
"base_sha": "02281f77ed7bfb56ff74a2f09a00aa03298e3268",
"head": "issue-705-ensure-autocommit-on",
"head_sha": "6df6da1a8eae5e1aedeb2bfa46709627cb58b923",
"merge_commit_sha": "50938ee6f846ddd792921f5a0353c73134dbbeda",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"requested_reviewers": null,
"requested_team_reviewers": null,
"diff": "@@ -13,6 +13,7 @@ Breaking changes:\n \n - ``table.foreign_keys`` now returns ``ForeignKey`` objects that are dataclasses rather than ``namedtuple`` instances, so they can no longer be unpacked or indexed as ``(table, column, other_table, other_column)`` tuples - access their fields by name instead. Compound (multi-column) foreign keys are now represented as a single ``ForeignKey`` with ``is_compound=True`` and populated ``columns``/``other_columns`` tuples, where ``column`` and ``other_column`` are ``None``. Previously they were returned as one ``ForeignKey`` per column, misleadingly suggesting several independent foreign keys. See :ref:`upgrading_3_to_4` for details. (:issue:`594`)\n - Removed support for using ``sqlean.py`` as a drop-in replacement for the Python standard library ``sqlite3`` module. ``sqlite-utils`` will now use ``pysqlite3`` if it is installed, otherwise it will use ``sqlite3`` from the standard library.\n+- The ``db.ensure_autocommit_off()`` context manager has been renamed to ``db.ensure_autocommit_on()``, because the old name described the opposite of what it did. The method temporarily puts the connection into driver-level autocommit mode - by setting ``isolation_level = None`` - so that statements such as ``PRAGMA journal_mode=wal`` can run outside of an implicit transaction. (:issue:`705`)\n \n Compound foreign key support:\n \n@@ -77,6 +77,8 @@ Python API changes\n \n **table.convert() no longer skips falsey values.** Matching the CLI change above, ``table.convert()`` now converts every value. The ``skip_false`` parameter has been removed - previously it defaulted to ``True``, skipping empty strings and other falsey values.\n \n+**ensure_autocommit_off() is now ensure_autocommit_on().** The ``db.ensure_autocommit_off()`` context manager has been renamed to ``db.ensure_autocommit_on()``. The old name described the opposite of what the method did: it temporarily puts the connection into driver-level autocommit mode (by setting ``isolation_level = None``), so that statements such as ``PRAGMA journal_mode=wal`` can run outside of an implicit transaction. The behavior is unchanged - update any calls to use the new name.\n+\n **View.enable_fts() has been removed.** The ``View`` class previously had an ``enable_fts()`` method that existed only to raise ``NotImplementedError`` - full-text search is not supported for views. Calling it now raises ``AttributeError`` like any other missing method.\n \n **ForeignKey is now a dataclass, not a namedtuple.** The ``ForeignKey`` objects returned by ``table.foreign_keys`` gained new fields - ``columns``, ``other_columns``, ``is_compound``, ``on_delete`` and ``on_update`` - so that compound (multi-column) foreign keys and foreign key actions can be represented. To make room for those fields cleanly ``ForeignKey`` is now a dataclass rather than a ``namedtuple``, so it can no longer be unpacked or indexed as a tuple. Access its fields by name instead:\n@@ -610,16 +610,22 @@ def rollback(self) -> None:\n self.conn.execute(\"ROLLBACK\")\n \n @contextlib.contextmanager\n- def ensure_autocommit_off(self) -> Generator[None, None, None]:\n+ def ensure_autocommit_on(self) -> Generator[None, None, None]:\n \"\"\"\n- Ensure autocommit is off for this database connection.\n+ Ensure the connection is in driver-level autocommit mode for the\n+ duration of a block of code.\n+\n+ This temporarily sets ``isolation_level = None`` on the underlying\n+ ``sqlite3`` connection, so the driver does not open implicit\n+ transactions. This is useful for statements such as\n+ ``PRAGMA journal_mode=wal`` which cannot run inside a transaction.\n \n Example usage::\n \n- with db.ensure_autocommit_off():\n+ with db.ensure_autocommit_on():\n # do stuff here\n \n- This will reset to the previous autocommit state at the end of the block.\n+ The previous ``isolation_level`` is restored at the end of the block.\n \"\"\"\n old_isolation_level = self.conn.isolation_level\n try:\n@@ -783,7 +789,7 @@ def query(\n if self.conn.in_transaction:\n cursor = self.conn.execute(sql, *args)\n else:\n- with self.ensure_autocommit_off():\n+ with self.ensure_autocommit_on():\n cursor = self.conn.execute(sql, *args)\n if cursor.description is None:\n raise ValueError(message)\n@@ -1085,7 +1091,7 @@ def enable_wal(self) -> None:\n \"\"\"\n if self.journal_mode != \"wal\":\n self._ensure_no_open_transaction(\"enable_wal()\")\n- with self.ensure_autocommit_off():\n+ with self.ensure_autocommit_on():\n self.execute(\"PRAGMA journal_mode=wal;\")\n \n def disable_wal(self) -> None:\n@@ -1097,7 +1103,7 @@ def disable_wal(self) -> None:\n \"\"\"\n if self.journal_mode != \"delete\":\n self._ensure_no_open_transaction(\"disable_wal()\")\n- with self.ensure_autocommit_off():\n+ with self.ensure_autocommit_on():\n self.execute(\"PRAGMA journal_mode=delete;\")\n \n def _ensure_no_open_transaction(self, operation: str) -> None:\n@@ -49,6 +49,17 @@ def test_disable_wal_inside_transaction_raises(db_path_tmpdir):\n assert [r[\"id\"] for r in db[\"test\"].rows] == [1]\n \n \n+def test_ensure_autocommit_on(db_path_tmpdir):\n+ db, path, tmpdir = db_path_tmpdir\n+ previous_isolation_level = db.conn.isolation_level\n+ assert previous_isolation_level is not None\n+ with db.ensure_autocommit_on():\n+ # isolation_level of None means driver-level autocommit mode\n+ assert db.conn.isolation_level is None\n+ # Restored afterwards\n+ assert db.conn.isolation_level == previous_isolation_level\n+\n+\n def test_enable_wal_noop_inside_transaction_is_allowed(db_path_tmpdir):\n # Calling enable_wal() when WAL is already enabled is a no-op,\n # so it is fine inside a transaction",
"comments": [
{
"url": "https://github.com/simonw/sqlite-utils/pull/776#issuecomment-4889209880",
"body": "## [Codecov](https://app.codecov.io/gh/simonw/sqlite-utils/pull/776?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison) Report\n:white_check_mark: All modified and coverable lines are covered by tests.\n:white_check_mark: Project coverage is 95.24%. Comparing base ([`07b603e`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/07b603e562af19f80c3d00eb59b9cf29331127ce?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)) to head ([`6df6da1`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/6df6da1a8eae5e1aedeb2bfa46709627cb58b923?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)).\n:warning: Report is 1 commits behind head on main.\n\n<details><summary>Additional details and impacted files</summary>\n\n\n\n```diff\n@@ Coverage Diff @@\n## main #776 +/- ##\n=======================================\n Coverage 95.24% 95.24% \n=======================================\n Files 9 9 \n Lines 3597 3597 \n=======================================\n Hits 3426 3426 \n Misses 171 171 \n```\n</details>\n\n[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/simonw/sqlite-utils/pull/776?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison). \n:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison).\n<details><summary> :rocket: New features to boost your workflow: </summary>\n\n- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.\n</details>",
"user": {
"login": "codecov[bot]",
"name": "codecov[bot]",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/in/254?v=4",
"id": 22429695
},
"id": 4889209880,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-06T04:53:41Z",
"updated_at": "2026-07-06T04:53:41Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<h2 dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/776?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Codecov</a> Report</h2>\n<p dir=\"auto\">✅ All modified and coverable lines are covered by tests.<br>\n✅ Project coverage is 95.24%. Comparing base (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/07b603e562af19f80c3d00eb59b9cf29331127ce?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">07b603e</code></a>) to head (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/6df6da1a8eae5e1aedeb2bfa46709627cb58b923?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">6df6da1</code></a>).<br>\n<g-emoji class=\"g-emoji\" alias=\"warning\">⚠️</g-emoji> Report is 1 commits behind head on main.</p>\n<details><summary>Additional details and impacted files</summary>\n<div class=\"highlight highlight-source-diff notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"@@ Coverage Diff @@\n## main #776 +/- ##\n=======================================\n Coverage 95.24% 95.24% \n=======================================\n Files 9 9 \n Lines 3597 3597 \n=======================================\n Hits 3426 3426 \n Misses 171 171 \"><pre class=\"notranslate\"><span class=\"pl-mdr\">@@ Coverage Diff @@</span>\n<span class=\"pl-c\"><span class=\"pl-c\">#</span># main #776 +/- ##</span>\n=======================================\n Coverage 95.24% 95.24% \n=======================================\n Files 9 9 \n Lines 3597 3597 \n=======================================\n Hits 3426 3426 \n Misses 171 171 </pre></div>\n</details>\n<p dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/776?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">☔ View full report in Codecov by Harness</a>.<br>\n📢 Have feedback on the report? <a href=\"https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Share it here</a>.</p>\n<details><summary> 🚀 New features to boost your workflow: </summary>\n<ul dir=\"auto\">\n<li>❄️ <a href=\"https://docs.codecov.com/docs/test-analytics\" rel=\"nofollow\">Test Analytics</a>: Detect flaky tests, report on failures, and find test suite problems.</li>\n</ul>\n</details>"
}
],
"created_at": "2026-07-06T04:52:11Z",
"updated_at": "2026-07-06T04:54:33Z",
"closed_at": "2026-07-06T04:54:30Z",
"merged_at": "2026-07-06T04:54:30Z",
"commits": 1,
"changed_files": 4,
"additions": 27,
"deletions": 7,
"display_url": "https://github.com/simonw/sqlite-utils/pull/776",
"display_title": "Rename ensure_autocommit_off() to ensure_autocommit_on()"
},
"url": "https://github.com/simonw/sqlite-utils/pull/776",
"title": "Rename ensure_autocommit_off() to ensure_autocommit_on()",
"diff": "@@ -13,6 +13,7 @@ Breaking changes:\n \n - ``table.foreign_keys`` now returns ``ForeignKey`` objects that are dataclasses rather than ``namedtuple`` instances, so they can no longer be unpacked or indexed as ``(table, column, other_table, other_column)`` tuples - access their fields by name instead. Compound (multi-column) foreign keys are now represented as a single ``ForeignKey`` with ``is_compound=True`` and populated ``columns``/``other_columns`` tuples, where ``column`` and ``other_column`` are ``None``. Previously they were returned as one ``ForeignKey`` per column, misleadingly suggesting several independent foreign keys. See :ref:`upgrading_3_to_4` for details. (:issue:`594`)\n - Removed support for using ``sqlean.py`` as a drop-in replacement for the Python standard library ``sqlite3`` module. ``sqlite-utils`` will now use ``pysqlite3`` if it is installed, otherwise it will use ``sqlite3`` from the standard library.\n+- The ``db.ensure_autocommit_off()`` context manager has been renamed to ``db.ensure_autocommit_on()``, because the old name described the opposite of what it did. The method temporarily puts the connection into driver-level autocommit mode - by setting ``isolation_level = None`` - so that statements such as ``PRAGMA journal_mode=wal`` can run outside of an implicit transaction. (:issue:`705`)\n \n Compound foreign key support:\n \n@@ -77,6 +77,8 @@ Python API changes\n \n **table.convert() no longer skips falsey values.** Matching the CLI change above, ``table.convert()`` now converts every value. The ``skip_false`` parameter has been removed - previously it defaulted to ``True``, skipping empty strings and other falsey values.\n \n+**ensure_autocommit_off() is now ensure_autocommit_on().** The ``db.ensure_autocommit_off()`` context manager has been renamed to ``db.ensure_autocommit_on()``. The old name described the opposite of what the method did: it temporarily puts the connection into driver-level autocommit mode (by setting ``isolation_level = None``), so that statements such as ``PRAGMA journal_mode=wal`` can run outside of an implicit transaction. The behavior is unchanged - update any calls to use the new name.\n+\n **View.enable_fts() has been removed.** The ``View`` class previously had an ``enable_fts()`` method that existed only to raise ``NotImplementedError`` - full-text search is not supported for views. Calling it now raises ``AttributeError`` like any other missing method.\n \n **ForeignKey is now a dataclass, not a namedtuple.** The ``ForeignKey`` objects returned by ``table.foreign_keys`` gained new fields - ``columns``, ``other_columns``, ``is_compound``, ``on_delete`` and ``on_update`` - so that compound (multi-column) foreign keys and foreign key actions can be represented. To make room for those fields cleanly ``ForeignKey`` is now a dataclass rather than a ``namedtuple``, so it can no longer be unpacked or indexed as a tuple. Access its fields by name instead:\n@@ -610,16 +610,22 @@ def rollback(self) -> None:\n self.conn.execute(\"ROLLBACK\")\n \n @contextlib.contextmanager\n- def ensure_autocommit_off(self) -> Generator[None, None, None]:\n+ def ensure_autocommit_on(self) -> Generator[None, None, None]:\n \"\"\"\n- Ensure autocommit is off for this database connection.\n+ Ensure the connection is in driver-level autocommit mode for the\n+ duration of a block of code.\n+\n+ This temporarily sets ``isolation_level = None`` on the underlying\n+ ``sqlite3`` connection, so the driver does not open implicit\n+ transactions. This is useful for statements such as\n+ ``PRAGMA journal_mode=wal`` which cannot run inside a transaction.\n \n Example usage::\n \n- with db.ensure_autocommit_off():\n+ with db.ensure_autocommit_on():\n # do stuff here\n \n- This will reset to the previous autocommit state at the end of the block.\n+ The previous ``isolation_level`` is restored at the end of the block.\n \"\"\"\n old_isolation_level = self.conn.isolation_level\n try:\n@@ -783,7 +789,7 @@ def query(\n if self.conn.in_transaction:\n cursor = self.conn.execute(sql, *args)\n else:\n- with self.ensure_autocommit_off():\n+ with self.ensure_autocommit_on():\n cursor = self.conn.execute(sql, *args)\n if cursor.description is None:\n raise ValueError(message)\n@@ -1085,7 +1091,7 @@ def enable_wal(self) -> None:\n \"\"\"\n if self.journal_mode != \"wal\":\n self._ensure_no_open_transaction(\"enable_wal()\")\n- with self.ensure_autocommit_off():\n+ with self.ensure_autocommit_on():\n self.execute(\"PRAGMA journal_mode=wal;\")\n \n def disable_wal(self) -> None:\n@@ -1097,7 +1103,7 @@ def disable_wal(self) -> None:\n \"\"\"\n if self.journal_mode != \"delete\":\n self._ensure_no_open_transaction(\"disable_wal()\")\n- with self.ensure_autocommit_off():\n+ with self.ensure_autocommit_on():\n self.execute(\"PRAGMA journal_mode=delete;\")\n \n def _ensure_no_open_transaction(self, operation: str) -> None:\n@@ -49,6 +49,17 @@ def test_disable_wal_inside_transaction_raises(db_path_tmpdir):\n assert [r[\"id\"] for r in db[\"test\"].rows] == [1]\n \n \n+def test_ensure_autocommit_on(db_path_tmpdir):\n+ db, path, tmpdir = db_path_tmpdir\n+ previous_isolation_level = db.conn.isolation_level\n+ assert previous_isolation_level is not None\n+ with db.ensure_autocommit_on():\n+ # isolation_level of None means driver-level autocommit mode\n+ assert db.conn.isolation_level is None\n+ # Restored afterwards\n+ assert db.conn.isolation_level == previous_isolation_level\n+\n+\n def test_enable_wal_noop_inside_transaction_is_allowed(db_path_tmpdir):\n # Calling enable_wal() when WAL is already enabled is a no-op,\n # so it is fine inside a transaction",
"comments": [
{
"url": "https://github.com/simonw/sqlite-utils/pull/776#issuecomment-4889209880",
"body": "## [Codecov](https://app.codecov.io/gh/simonw/sqlite-utils/pull/776?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison) Report\n:white_check_mark: All modified and coverable lines are covered by tests.\n:white_check_mark: Project coverage is 95.24%. Comparing base ([`07b603e`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/07b603e562af19f80c3d00eb59b9cf29331127ce?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)) to head ([`6df6da1`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/6df6da1a8eae5e1aedeb2bfa46709627cb58b923?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)).\n:warning: Report is 1 commits behind head on main.\n\n<details><summary>Additional details and impacted files</summary>\n\n\n\n```diff\n@@ Coverage Diff @@\n## main #776 +/- ##\n=======================================\n Coverage 95.24% 95.24% \n=======================================\n Files 9 9 \n Lines 3597 3597 \n=======================================\n Hits 3426 3426 \n Misses 171 171 \n```\n</details>\n\n[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/simonw/sqlite-utils/pull/776?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison). \n:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison).\n<details><summary> :rocket: New features to boost your workflow: </summary>\n\n- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.\n</details>",
"user": {
"login": "codecov[bot]",
"name": "codecov[bot]",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/in/254?v=4",
"id": 22429695
},
"id": 4889209880,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-06T04:53:41Z",
"updated_at": "2026-07-06T04:53:41Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<h2 dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/776?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Codecov</a> Report</h2>\n<p dir=\"auto\">✅ All modified and coverable lines are covered by tests.<br>\n✅ Project coverage is 95.24%. Comparing base (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/07b603e562af19f80c3d00eb59b9cf29331127ce?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">07b603e</code></a>) to head (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/6df6da1a8eae5e1aedeb2bfa46709627cb58b923?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">6df6da1</code></a>).<br>\n<g-emoji class=\"g-emoji\" alias=\"warning\">⚠️</g-emoji> Report is 1 commits behind head on main.</p>\n<details><summary>Additional details and impacted files</summary>\n<div class=\"highlight highlight-source-diff notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"@@ Coverage Diff @@\n## main #776 +/- ##\n=======================================\n Coverage 95.24% 95.24% \n=======================================\n Files 9 9 \n Lines 3597 3597 \n=======================================\n Hits 3426 3426 \n Misses 171 171 \"><pre class=\"notranslate\"><span class=\"pl-mdr\">@@ Coverage Diff @@</span>\n<span class=\"pl-c\"><span class=\"pl-c\">#</span># main #776 +/- ##</span>\n=======================================\n Coverage 95.24% 95.24% \n=======================================\n Files 9 9 \n Lines 3597 3597 \n=======================================\n Hits 3426 3426 \n Misses 171 171 </pre></div>\n</details>\n<p dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/776?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">☔ View full report in Codecov by Harness</a>.<br>\n📢 Have feedback on the report? <a href=\"https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Share it here</a>.</p>\n<details><summary> 🚀 New features to boost your workflow: </summary>\n<ul dir=\"auto\">\n<li>❄️ <a href=\"https://docs.codecov.com/docs/test-analytics\" rel=\"nofollow\">Test Analytics</a>: Detect flaky tests, report on failures, and find test suite problems.</li>\n</ul>\n</details>"
}
],
"display_url": "https://github.com/simonw/sqlite-utils/pull/776",
"display_title": "Rename ensure_autocommit_off() to ensure_autocommit_on()"
}MCP tool call
codex_apps.github.fetch_pr
{
"repo_full_name": "simonw/sqlite-utils",
"pr_number": 775
}Action completed.
{
"pull_request": {
"url": "https://github.com/simonw/sqlite-utils/pull/775",
"number": 775,
"state": "closed",
"merged": true,
"mergeable": false,
"draft": false,
"body": "Vendor https://github.com/asg017/sqlite-versions/tree/71ea0de37ae739c33e447af91ba71dda8fcf22e6 and make it more robust against `sqlite.org` timeouts.\r\n\r\nRefs:\r\n- #774 \r\n\r\n<!-- readthedocs-preview sqlite-utils start -->\r\n----\r\n📚 Documentation preview 📚: https://sqlite-utils--775.org.readthedocs.build/en/775/\r\n\r\n<!-- readthedocs-preview sqlite-utils end -->",
"title": "Vendor SQLite version setup action",
"base": "main",
"base_sha": "07b603e562af19f80c3d00eb59b9cf29331127ce",
"head": "codex/vendor-sqlite-version-action",
"head_sha": "518dec1b4f72d9b28a66b462470adb809012fce6",
"merge_commit_sha": "02281f77ed7bfb56ff74a2f09a00aa03298e3268",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"requested_reviewers": null,
"requested_team_reviewers": null,
"diff": "@@ -0,0 +1,39 @@\n+name: \"Setup SQLite version\"\n+description: \"Build and activate a specific SQLite version from its amalgamation archive\"\n+inputs:\n+ version:\n+ description: \"The SQLite version to install\"\n+ required: true\n+ cflags:\n+ description: \"CFLAGS to use when compiling SQLite\"\n+ required: false\n+ default: \"\"\n+ skip-activate:\n+ description: \"Set to true to skip modifying the library path\"\n+ required: false\n+ default: \"false\"\n+ fallback-urls:\n+ description: \"Whitespace-separated fallback download URLs to try after sqlite.org\"\n+ required: false\n+ default: \"\"\n+outputs:\n+ sqlite-location:\n+ description: \"Directory containing the compiled SQLite library\"\n+ value: ${{ steps.build.outputs.sqlite-location }}\n+runs:\n+ using: \"composite\"\n+ steps:\n+ - shell: bash\n+ run: mkdir -p \"$RUNNER_TEMP/sqlite-versions/downloads\"\n+ - uses: actions/cache@v6\n+ with:\n+ path: ${{ runner.temp }}/sqlite-versions/downloads\n+ key: setup-sqlite-version-${{ inputs.version }}-amalgamation-v1\n+ - id: build\n+ shell: bash\n+ run: bash \"$GITHUB_ACTION_PATH/setup-sqlite-version.sh\"\n+ env:\n+ SQLITE_VERSION: ${{ inputs.version }}\n+ SQLITE_CFLAGS: ${{ inputs.cflags }}\n+ SQLITE_SKIP_ACTIVATE: ${{ inputs.skip-activate }}\n+ SQLITE_EXTRA_FALLBACK_URLS: ${{ inputs.fallback-urls }}\n@@ -0,0 +1,144 @@\n+#!/usr/bin/env bash\n+set -euo pipefail\n+\n+version_spec=\"${SQLITE_VERSION:?SQLITE_VERSION is required}\"\n+cflags=\"${SQLITE_CFLAGS:-}\"\n+skip_activate=\"${SQLITE_SKIP_ACTIVATE:-false}\"\n+extra_fallback_urls=\"${SQLITE_EXTRA_FALLBACK_URLS:-}\"\n+\n+case \"$version_spec\" in\n+ 3.46 | 3.46.0)\n+ sqlite_version=\"3.46.0\"\n+ sqlite_year=\"2024\"\n+ amalgamation_id=\"3460000\"\n+ builtin_fallback_urls=\"https://static.simonwillison.net/static/2026/sqlite-amalgamation-3460000.zip\"\n+ ;;\n+ 3.23.1)\n+ sqlite_version=\"3.23.1\"\n+ sqlite_year=\"2018\"\n+ amalgamation_id=\"3230100\"\n+ builtin_fallback_urls=\"https://static.simonwillison.net/static/2026/sqlite-amalgamation-3230100.zip\"\n+ ;;\n+ *)\n+ echo \"::error::Unsupported SQLite version '$version_spec'. Add its release year and amalgamation id to $GITHUB_ACTION_PATH/setup-sqlite-version.sh.\"\n+ exit 1\n+ ;;\n+esac\n+\n+case \"$(uname -s)\" in\n+ Linux)\n+ library_name=\"libsqlite3.so.0\"\n+ library_path_var=\"LD_LIBRARY_PATH\"\n+ ;;\n+ Darwin)\n+ library_name=\"libsqlite3.dylib\"\n+ library_path_var=\"DYLD_LIBRARY_PATH\"\n+ ;;\n+ *)\n+ echo \"::error::Unsupported platform $(uname -s)\"\n+ exit 1\n+ ;;\n+esac\n+\n+runner_temp=\"${RUNNER_TEMP:-}\"\n+if [ -z \"$runner_temp\" ]; then\n+ runner_temp=\"$(mktemp -d)\"\n+fi\n+\n+filename=\"sqlite-amalgamation-${amalgamation_id}\"\n+official_url=\"https://www.sqlite.org/${sqlite_year}/${filename}.zip\"\n+download_dir=\"${runner_temp}/sqlite-versions/downloads\"\n+source_root=\"${runner_temp}/sqlite-versions/source\"\n+source_dir=\"${source_root}/${filename}\"\n+build_dir=\"${runner_temp}/sqlite-versions/build/${sqlite_version}\"\n+archive_path=\"${download_dir}/${filename}.zip\"\n+\n+mkdir -p \"$download_dir\" \"$source_root\" \"$build_dir\"\n+\n+download_archive() {\n+ local url\n+ local candidate_path=\"${archive_path}.tmp\"\n+ local urls=(\"$official_url\")\n+\n+ for url in $builtin_fallback_urls $extra_fallback_urls; do\n+ urls+=(\"$url\")\n+ done\n+\n+ rm -f \"$candidate_path\"\n+ for url in \"${urls[@]}\"; do\n+ echo \"Downloading SQLite ${sqlite_version} amalgamation from ${url}\"\n+ if curl \\\n+ --fail \\\n+ --location \\\n+ --show-error \\\n+ --retry 5 \\\n+ --retry-delay 2 \\\n+ --retry-max-time 180 \\\n+ --retry-all-errors \\\n+ --connect-timeout 20 \\\n+ --max-time 240 \\\n+ --output \"$candidate_path\" \\\n+ \"$url\"; then\n+ mv \"$candidate_path\" \"$archive_path\"\n+ return 0\n+ fi\n+\n+ echo \"::warning::Download failed from ${url}\"\n+ rm -f \"$candidate_path\"\n+ done\n+\n+ echo \"::error::Could not download SQLite ${sqlite_version} amalgamation\"\n+ return 1\n+}\n+\n+if [ ! -f \"${source_dir}/sqlite3.c\" ]; then\n+ if [ ! -f \"$archive_path\" ]; then\n+ download_archive\n+ fi\n+\n+ rm -rf \"$source_dir\"\n+ unzip -q \"$archive_path\" -d \"$source_root\"\n+fi\n+\n+if [ ! -f \"${source_dir}/sqlite3.c\" ]; then\n+ echo \"::error::Expected ${source_dir}/sqlite3.c after extracting ${archive_path}\"\n+ exit 1\n+fi\n+\n+read -r -a cflag_args <<< \"$cflags\"\n+\n+echo \"Compiling SQLite ${sqlite_version} to ${build_dir}/${library_name}\"\n+gcc \\\n+ -fPIC \\\n+ -shared \\\n+ \"${cflag_args[@]}\" \\\n+ \"${source_dir}/sqlite3.c\" \\\n+ \"-I${source_dir}\" \\\n+ -o \"${build_dir}/${library_name}\"\n+\n+if [ \"$library_name\" = \"libsqlite3.so.0\" ]; then\n+ ln -sf \"$library_name\" \"${build_dir}/libsqlite3.so\"\n+fi\n+\n+if [ -n \"${GITHUB_OUTPUT:-}\" ]; then\n+ echo \"sqlite-location=${build_dir}\" >> \"$GITHUB_OUTPUT\"\n+else\n+ echo \"sqlite-location=${build_dir}\"\n+fi\n+\n+case \"$(printf '%s' \"$skip_activate\" | tr '[:upper:]' '[:lower:]')\" in\n+ true | 1 | yes)\n+ echo \"Skipping ${library_path_var} activation\"\n+ ;;\n+ *)\n+ existing_value=\"${!library_path_var:-}\"\n+ if [ -n \"${GITHUB_ENV:-}\" ]; then\n+ if [ -n \"$existing_value\" ]; then\n+ echo \"${library_path_var}=${build_dir}:${existing_value}\" >> \"$GITHUB_ENV\"\n+ else\n+ echo \"${library_path_var}=${build_dir}\" >> \"$GITHUB_ENV\"\n+ fi\n+ fi\n+ echo \"Added ${build_dir} to ${library_path_var}\"\n+ ;;\n+esac\n@@ -18,16 +18,16 @@ jobs:\n \"3.23.1\", # 2018-04-10, before UPSERT\n ]\n steps:\n- - uses: actions/checkout@v4\n+ - uses: actions/checkout@v7\n - name: Set up Python ${{ matrix.python-version }}\n- uses: actions/setup-python@v5\n+ uses: actions/setup-python@v6\n with:\n python-version: ${{ matrix.python-version }}\n allow-prereleases: true\n cache: pip\n cache-dependency-path: pyproject.toml\n - name: Set up SQLite ${{ matrix.sqlite-version }}\n- uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6\n+ uses: ./.github/actions/setup-sqlite-version\n with:\n version: ${{ matrix.sqlite-version }}\n cflags: \"-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1\"",
"comments": [
{
"url": "https://github.com/simonw/sqlite-utils/pull/775#issuecomment-4889121364",
"body": "## [Codecov](https://app.codecov.io/gh/simonw/sqlite-utils/pull/775?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison) Report\n:white_check_mark: All modified and coverable lines are covered by tests.\n:white_check_mark: Project coverage is 95.24%. Comparing base ([`07b603e`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/07b603e562af19f80c3d00eb59b9cf29331127ce?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)) to head ([`518dec1`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/518dec1b4f72d9b28a66b462470adb809012fce6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)).\n\n<details><summary>Additional details and impacted files</summary>\n\n\n\n```diff\n@@ Coverage Diff @@\n## main #775 +/- ##\n=======================================\n Coverage 95.24% 95.24% \n=======================================\n Files 9 9 \n Lines 3597 3597 \n=======================================\n Hits 3426 3426 \n Misses 171 171 \n```\n</details>\n\n[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/simonw/sqlite-utils/pull/775?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison). \n:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison).\n<details><summary> :rocket: New features to boost your workflow: </summary>\n\n- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.\n</details>",
"user": {
"login": "codecov[bot]",
"name": "codecov[bot]",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/in/254?v=4",
"id": 22429695
},
"id": 4889121364,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-06T04:36:08Z",
"updated_at": "2026-07-06T04:36:08Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<h2 dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/775?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Codecov</a> Report</h2>\n<p dir=\"auto\">✅ All modified and coverable lines are covered by tests.<br>\n✅ Project coverage is 95.24%. Comparing base (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/07b603e562af19f80c3d00eb59b9cf29331127ce?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">07b603e</code></a>) to head (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/518dec1b4f72d9b28a66b462470adb809012fce6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">518dec1</code></a>).</p>\n<details><summary>Additional details and impacted files</summary>\n<div class=\"highlight highlight-source-diff notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"@@ Coverage Diff @@\n## main #775 +/- ##\n=======================================\n Coverage 95.24% 95.24% \n=======================================\n Files 9 9 \n Lines 3597 3597 \n=======================================\n Hits 3426 3426 \n Misses 171 171 \"><pre class=\"notranslate\"><span class=\"pl-mdr\">@@ Coverage Diff @@</span>\n<span class=\"pl-c\"><span class=\"pl-c\">#</span># main #775 +/- ##</span>\n=======================================\n Coverage 95.24% 95.24% \n=======================================\n Files 9 9 \n Lines 3597 3597 \n=======================================\n Hits 3426 3426 \n Misses 171 171 </pre></div>\n</details>\n<p dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/775?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">☔ View full report in Codecov by Harness</a>.<br>\n📢 Have feedback on the report? <a href=\"https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Share it here</a>.</p>\n<details><summary> 🚀 New features to boost your workflow: </summary>\n<ul dir=\"auto\">\n<li>❄️ <a href=\"https://docs.codecov.com/docs/test-analytics\" rel=\"nofollow\">Test Analytics</a>: Detect flaky tests, report on failures, and find test suite problems.</li>\n</ul>\n</details>"
}
],
"created_at": "2026-07-06T04:34:30Z",
"updated_at": "2026-07-06T04:42:31Z",
"closed_at": "2026-07-06T04:42:23Z",
"merged_at": "2026-07-06T04:42:23Z",
"commits": 1,
"changed_files": 3,
"additions": 186,
"deletions": 3,
"display_url": "https://github.com/simonw/sqlite-utils/pull/775",
"display_title": "Vendor SQLite version setup action"
},
"url": "https://github.com/simonw/sqlite-utils/pull/775",
"title": "Vendor SQLite version setup action",
"diff": "@@ -0,0 +1,39 @@\n+name: \"Setup SQLite version\"\n+description: \"Build and activate a specific SQLite version from its amalgamation archive\"\n+inputs:\n+ version:\n+ description: \"The SQLite version to install\"\n+ required: true\n+ cflags:\n+ description: \"CFLAGS to use when compiling SQLite\"\n+ required: false\n+ default: \"\"\n+ skip-activate:\n+ description: \"Set to true to skip modifying the library path\"\n+ required: false\n+ default: \"false\"\n+ fallback-urls:\n+ description: \"Whitespace-separated fallback download URLs to try after sqlite.org\"\n+ required: false\n+ default: \"\"\n+outputs:\n+ sqlite-location:\n+ description: \"Directory containing the compiled SQLite library\"\n+ value: ${{ steps.build.outputs.sqlite-location }}\n+runs:\n+ using: \"composite\"\n+ steps:\n+ - shell: bash\n+ run: mkdir -p \"$RUNNER_TEMP/sqlite-versions/downloads\"\n+ - uses: actions/cache@v6\n+ with:\n+ path: ${{ runner.temp }}/sqlite-versions/downloads\n+ key: setup-sqlite-version-${{ inputs.version }}-amalgamation-v1\n+ - id: build\n+ shell: bash\n+ run: bash \"$GITHUB_ACTION_PATH/setup-sqlite-version.sh\"\n+ env:\n+ SQLITE_VERSION: ${{ inputs.version }}\n+ SQLITE_CFLAGS: ${{ inputs.cflags }}\n+ SQLITE_SKIP_ACTIVATE: ${{ inputs.skip-activate }}\n+ SQLITE_EXTRA_FALLBACK_URLS: ${{ inputs.fallback-urls }}\n@@ -0,0 +1,144 @@\n+#!/usr/bin/env bash\n+set -euo pipefail\n+\n+version_spec=\"${SQLITE_VERSION:?SQLITE_VERSION is required}\"\n+cflags=\"${SQLITE_CFLAGS:-}\"\n+skip_activate=\"${SQLITE_SKIP_ACTIVATE:-false}\"\n+extra_fallback_urls=\"${SQLITE_EXTRA_FALLBACK_URLS:-}\"\n+\n+case \"$version_spec\" in\n+ 3.46 | 3.46.0)\n+ sqlite_version=\"3.46.0\"\n+ sqlite_year=\"2024\"\n+ amalgamation_id=\"3460000\"\n+ builtin_fallback_urls=\"https://static.simonwillison.net/static/2026/sqlite-amalgamation-3460000.zip\"\n+ ;;\n+ 3.23.1)\n+ sqlite_version=\"3.23.1\"\n+ sqlite_year=\"2018\"\n+ amalgamation_id=\"3230100\"\n+ builtin_fallback_urls=\"https://static.simonwillison.net/static/2026/sqlite-amalgamation-3230100.zip\"\n+ ;;\n+ *)\n+ echo \"::error::Unsupported SQLite version '$version_spec'. Add its release year and amalgamation id to $GITHUB_ACTION_PATH/setup-sqlite-version.sh.\"\n+ exit 1\n+ ;;\n+esac\n+\n+case \"$(uname -s)\" in\n+ Linux)\n+ library_name=\"libsqlite3.so.0\"\n+ library_path_var=\"LD_LIBRARY_PATH\"\n+ ;;\n+ Darwin)\n+ library_name=\"libsqlite3.dylib\"\n+ library_path_var=\"DYLD_LIBRARY_PATH\"\n+ ;;\n+ *)\n+ echo \"::error::Unsupported platform $(uname -s)\"\n+ exit 1\n+ ;;\n+esac\n+\n+runner_temp=\"${RUNNER_TEMP:-}\"\n+if [ -z \"$runner_temp\" ]; then\n+ runner_temp=\"$(mktemp -d)\"\n+fi\n+\n+filename=\"sqlite-amalgamation-${amalgamation_id}\"\n+official_url=\"https://www.sqlite.org/${sqlite_year}/${filename}.zip\"\n+download_dir=\"${runner_temp}/sqlite-versions/downloads\"\n+source_root=\"${runner_temp}/sqlite-versions/source\"\n+source_dir=\"${source_root}/${filename}\"\n+build_dir=\"${runner_temp}/sqlite-versions/build/${sqlite_version}\"\n+archive_path=\"${download_dir}/${filename}.zip\"\n+\n+mkdir -p \"$download_dir\" \"$source_root\" \"$build_dir\"\n+\n+download_archive() {\n+ local url\n+ local candidate_path=\"${archive_path}.tmp\"\n+ local urls=(\"$official_url\")\n+\n+ for url in $builtin_fallback_urls $extra_fallback_urls; do\n+ urls+=(\"$url\")\n+ done\n+\n+ rm -f \"$candidate_path\"\n+ for url in \"${urls[@]}\"; do\n+ echo \"Downloading SQLite ${sqlite_version} amalgamation from ${url}\"\n+ if curl \\\n+ --fail \\\n+ --location \\\n+ --show-error \\\n+ --retry 5 \\\n+ --retry-delay 2 \\\n+ --retry-max-time 180 \\\n+ --retry-all-errors \\\n+ --connect-timeout 20 \\\n+ --max-time 240 \\\n+ --output \"$candidate_path\" \\\n+ \"$url\"; then\n+ mv \"$candidate_path\" \"$archive_path\"\n+ return 0\n+ fi\n+\n+ echo \"::warning::Download failed from ${url}\"\n+ rm -f \"$candidate_path\"\n+ done\n+\n+ echo \"::error::Could not download SQLite ${sqlite_version} amalgamation\"\n+ return 1\n+}\n+\n+if [ ! -f \"${source_dir}/sqlite3.c\" ]; then\n+ if [ ! -f \"$archive_path\" ]; then\n+ download_archive\n+ fi\n+\n+ rm -rf \"$source_dir\"\n+ unzip -q \"$archive_path\" -d \"$source_root\"\n+fi\n+\n+if [ ! -f \"${source_dir}/sqlite3.c\" ]; then\n+ echo \"::error::Expected ${source_dir}/sqlite3.c after extracting ${archive_path}\"\n+ exit 1\n+fi\n+\n+read -r -a cflag_args <<< \"$cflags\"\n+\n+echo \"Compiling SQLite ${sqlite_version} to ${build_dir}/${library_name}\"\n+gcc \\\n+ -fPIC \\\n+ -shared \\\n+ \"${cflag_args[@]}\" \\\n+ \"${source_dir}/sqlite3.c\" \\\n+ \"-I${source_dir}\" \\\n+ -o \"${build_dir}/${library_name}\"\n+\n+if [ \"$library_name\" = \"libsqlite3.so.0\" ]; then\n+ ln -sf \"$library_name\" \"${build_dir}/libsqlite3.so\"\n+fi\n+\n+if [ -n \"${GITHUB_OUTPUT:-}\" ]; then\n+ echo \"sqlite-location=${build_dir}\" >> \"$GITHUB_OUTPUT\"\n+else\n+ echo \"sqlite-location=${build_dir}\"\n+fi\n+\n+case \"$(printf '%s' \"$skip_activate\" | tr '[:upper:]' '[:lower:]')\" in\n+ true | 1 | yes)\n+ echo \"Skipping ${library_path_var} activation\"\n+ ;;\n+ *)\n+ existing_value=\"${!library_path_var:-}\"\n+ if [ -n \"${GITHUB_ENV:-}\" ]; then\n+ if [ -n \"$existing_value\" ]; then\n+ echo \"${library_path_var}=${build_dir}:${existing_value}\" >> \"$GITHUB_ENV\"\n+ else\n+ echo \"${library_path_var}=${build_dir}\" >> \"$GITHUB_ENV\"\n+ fi\n+ fi\n+ echo \"Added ${build_dir} to ${library_path_var}\"\n+ ;;\n+esac\n@@ -18,16 +18,16 @@ jobs:\n \"3.23.1\", # 2018-04-10, before UPSERT\n ]\n steps:\n- - uses: actions/checkout@v4\n+ - uses: actions/checkout@v7\n - name: Set up Python ${{ matrix.python-version }}\n- uses: actions/setup-python@v5\n+ uses: actions/setup-python@v6\n with:\n python-version: ${{ matrix.python-version }}\n allow-prereleases: true\n cache: pip\n cache-dependency-path: pyproject.toml\n - name: Set up SQLite ${{ matrix.sqlite-version }}\n- uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6\n+ uses: ./.github/actions/setup-sqlite-version\n with:\n version: ${{ matrix.sqlite-version }}\n cflags: \"-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1\"",
"comments": [
{
"url": "https://github.com/simonw/sqlite-utils/pull/775#issuecomment-4889121364",
"body": "## [Codecov](https://app.codecov.io/gh/simonw/sqlite-utils/pull/775?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison) Report\n:white_check_mark: All modified and coverable lines are covered by tests.\n:white_check_mark: Project coverage is 95.24%. Comparing base ([`07b603e`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/07b603e562af19f80c3d00eb59b9cf29331127ce?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)) to head ([`518dec1`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/518dec1b4f72d9b28a66b462470adb809012fce6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)).\n\n<details><summary>Additional details and impacted files</summary>\n\n\n\n```diff\n@@ Coverage Diff @@\n## main #775 +/- ##\n=======================================\n Coverage 95.24% 95.24% \n=======================================\n Files 9 9 \n Lines 3597 3597 \n=======================================\n Hits 3426 3426 \n Misses 171 171 \n```\n</details>\n\n[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/simonw/sqlite-utils/pull/775?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison). \n:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison).\n<details><summary> :rocket: New features to boost your workflow: </summary>\n\n- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.\n</details>",
"user": {
"login": "codecov[bot]",
"name": "codecov[bot]",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/in/254?v=4",
"id": 22429695
},
"id": 4889121364,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-06T04:36:08Z",
"updated_at": "2026-07-06T04:36:08Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<h2 dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/775?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Codecov</a> Report</h2>\n<p dir=\"auto\">✅ All modified and coverable lines are covered by tests.<br>\n✅ Project coverage is 95.24%. Comparing base (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/07b603e562af19f80c3d00eb59b9cf29331127ce?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">07b603e</code></a>) to head (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/518dec1b4f72d9b28a66b462470adb809012fce6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">518dec1</code></a>).</p>\n<details><summary>Additional details and impacted files</summary>\n<div class=\"highlight highlight-source-diff notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"@@ Coverage Diff @@\n## main #775 +/- ##\n=======================================\n Coverage 95.24% 95.24% \n=======================================\n Files 9 9 \n Lines 3597 3597 \n=======================================\n Hits 3426 3426 \n Misses 171 171 \"><pre class=\"notranslate\"><span class=\"pl-mdr\">@@ Coverage Diff @@</span>\n<span class=\"pl-c\"><span class=\"pl-c\">#</span># main #775 +/- ##</span>\n=======================================\n Coverage 95.24% 95.24% \n=======================================\n Files 9 9 \n Lines 3597 3597 \n=======================================\n Hits 3426 3426 \n Misses 171 171 </pre></div>\n</details>\n<p dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/775?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">☔ View full report in Codecov by Harness</a>.<br>\n📢 Have feedback on the report? <a href=\"https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Share it here</a>.</p>\n<details><summary> 🚀 New features to boost your workflow: </summary>\n<ul dir=\"auto\">\n<li>❄️ <a href=\"https://docs.codecov.com/docs/test-analytics\" rel=\"nofollow\">Test Analytics</a>: Detect flaky tests, report on failures, and find test suite problems.</li>\n</ul>\n</details>"
}
],
"display_url": "https://github.com/simonw/sqlite-utils/pull/775",
"display_title": "Vendor SQLite version setup action"
}MCP tool call
codex_apps.github.fetch_pr
{
"repo_full_name": "simonw/sqlite-utils",
"pr_number": 782
}Action completed.
{
"pull_request": {
"url": "https://github.com/simonw/sqlite-utils/pull/782",
"number": 782,
"state": "closed",
"merged": true,
"mergeable": false,
"draft": false,
"body": "Refs:\r\n- #783 \r\n- #781\r\n- #769 \r\n\r\nTwo behaviour regressions in the 4.0 insert/upsert rewrite broke callers\r\n(notably Datasette's write API) that operate on tables without an explicit\r\nprimary key. Both are fixed here with regression tests.\r\n\r\n1. rowid (and its aliases _rowid_/oid) were rejected as a primary key.\r\n Table.pks already reports [\"rowid\"] for a rowid table, but the new pk\r\n validation raised InvalidColumns because rowid is not listed among the\r\n table's columns, and the insert success path then raised KeyError when\r\n looking up the pk value. rowid aliases are now accepted for rowid tables\r\n and resolve directly to the rowid.\r\n\r\n2. An ignored insert (INSERT OR IGNORE that matched an existing row) no\r\n longer populated last_rowid, and only set last_pk when an explicit pk=\r\n was passed. It now locates the existing conflicting row by its primary\r\n key values and reports that row's rowid and pk, rather than relying on\r\n the connection's last inserted rowid.\r\n\r\nAdd a shared ROWID_ALIASES constant for the rowid alias names.\r\n\r\nCo-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>\r\nClaude-Session: https://claude.ai/code/session_01E7af8SxFZqiCerJB6MqKnY\r\n\r\n<!-- readthedocs-preview sqlite-utils start -->\r\n----\r\n📚 Documentation preview 📚: https://sqlite-utils--782.org.readthedocs.build/en/782/\r\n\r\n<!-- readthedocs-preview sqlite-utils end -->",
"title": "Fix rowid pk and last_rowid regressions in insert/upsert",
"base": "main",
"base_sha": "d314d04215f7337d42c847214861ec7ffe0bf757",
"head": "claude/datasette-tests-mown-2uaqtt",
"head_sha": "d53e3cbb8986222226820789e94a12d1d09c2861",
"merge_commit_sha": "60811e730509667f702bc08f9bf5fc3fe13b7f45",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"requested_reviewers": null,
"requested_team_reviewers": null,
"diff": "@@ -53,6 +53,11 @@\n \n SQLITE_MAX_VARS = 999\n \n+# Names that refer to a rowid table's implicit integer primary key. These are\n+# valid primary key targets even though they are not listed among a table's\n+# columns. See https://www.sqlite.org/lang_createtable.html#rowid\n+ROWID_ALIASES = frozenset({\"rowid\", \"_rowid_\", \"oid\"})\n+\n _quote_fts_re = re.compile(r'\\s+|(\".*?\")')\n \n _virtual_table_using_re = re.compile(\n@@ -4343,10 +4348,14 @@ def insert_all(\n if pk and not hash_id and self.exists():\n pk_cols = [pk] if isinstance(pk, str) else list(pk)\n existing_columns = self.columns_dict\n+ # rowid and its aliases are valid primary keys for a rowid table\n+ # even though they are not listed among the table's columns\n+ rowid_aliases = ROWID_ALIASES if self.use_rowid else frozenset()\n missing_pk_cols = [\n col\n for col in pk_cols\n- if resolve_casing(col, existing_columns) not in existing_columns\n+ if col.lower() not in rowid_aliases\n+ and resolve_casing(col, existing_columns) not in existing_columns\n ]\n if missing_pk_cols:\n invalid_pk_error = InvalidColumns(\n@@ -4512,40 +4521,72 @@ def insert_all(\n if not upsert and result is not None:\n ignored_insert = ignore and result.rowcount == 0\n if ignored_insert:\n+ # The row was not inserted because it conflicts with an\n+ # existing row. Point last_pk / last_rowid at that existing\n+ # row when we can identify it from the record's primary key\n+ # values, rather than leaving them stale or unset.\n if list_mode:\n- first_record_list = cast(Sequence[Any], first_record)\n- if hash_id:\n- pass\n- elif isinstance(pk, str):\n- pk_index = column_names.index(\n- resolve_casing(pk, column_names)\n- )\n- self.last_pk = first_record_list[pk_index]\n- elif pk:\n- self.last_pk = tuple(\n- first_record_list[\n- column_names.index(resolve_casing(p, column_names))\n- ]\n- for p in pk\n- )\n+ first_record_dict = dict(\n+ zip(column_names, cast(Sequence[Any], first_record))\n+ )\n else:\n first_record_dict = cast(Dict[str, Any], first_record)\n- if hash_id:\n- self.last_pk = hash_record(\n- first_record_dict, hash_id_columns\n- )\n- elif isinstance(pk, str):\n- self.last_pk = first_record_dict[\n- resolve_casing(pk, first_record_dict)\n+ if hash_id:\n+ self.last_pk = hash_record(first_record_dict, hash_id_columns)\n+ elif isinstance(pk, str):\n+ self.last_pk = first_record_dict[\n+ resolve_casing(pk, first_record_dict)\n+ ]\n+ elif pk:\n+ self.last_pk = tuple(\n+ first_record_dict[resolve_casing(p, first_record_dict)]\n+ for p in pk\n+ )\n+ # Locate the existing conflicting row using its primary key\n+ # columns so we can report its rowid (and pk if not already\n+ # known). Falls back to leaving them unset if the conflict\n+ # cannot be resolved to a pk lookup (e.g. a UNIQUE column).\n+ key_cols: Optional[List[str]] = None\n+ if isinstance(pk, str):\n+ key_cols = [pk]\n+ elif pk:\n+ key_cols = list(pk)\n+ elif not hash_id and not self.use_rowid:\n+ key_cols = self.pks\n+ if key_cols:\n+ try:\n+ key_values = [\n+ first_record_dict[resolve_casing(c, first_record_dict)]\n+ for c in key_cols\n ]\n- elif pk:\n- self.last_pk = tuple(\n- first_record_dict[resolve_casing(p, first_record_dict)]\n- for p in pk\n+ except KeyError:\n+ key_values = None\n+ if key_values is not None:\n+ where = \" and \".join(\n+ \"{} = ?\".format(quote_identifier(c)) for c in key_cols\n )\n+ existing = self.db.execute(\n+ \"select rowid from {} where {} limit 1\".format(\n+ quote_identifier(self.name), where\n+ ),\n+ key_values,\n+ ).fetchone()\n+ if existing is not None:\n+ self.last_rowid = existing[0]\n+ # On a primary key conflict the record's pk\n+ # values identify the existing row\n+ if self.last_pk is None:\n+ self.last_pk = (\n+ key_values[0]\n+ if len(key_cols) == 1\n+ else tuple(key_values)\n+ )\n else:\n self.last_rowid = result.lastrowid\n- if (hash_id or pk) and self.last_rowid:\n+ # A rowid-alias pk resolves directly to the rowid, so there\n+ # is no separate pk column to look up\n+ rowid_pk = isinstance(pk, str) and pk.lower() in ROWID_ALIASES\n+ if (hash_id or (pk and not rowid_pk)) and self.last_rowid:\n # Set self.last_pk to the pk(s) for that rowid\n row = list(self.rows_where(\"rowid = ?\", [self.last_rowid]))[0]\n if hash_id:\n@@ -990,6 +990,96 @@ def test_insert_ignore(fresh_db):\n assert rows == [{\"id\": 1, \"bar\": 2}]\n \n \n+def test_insert_ignore_reports_existing_row(fresh_db):\n+ # An ignored insert (row already exists) should point last_rowid and\n+ # last_pk at the existing conflicting row - see the Datasette insert API\n+ fresh_db[\"docs\"].insert({\"id\": 1, \"title\": \"Exists\"}, pk=\"id\")\n+ # Insert a conflicting row with ignore=True and no explicit pk=\n+ table = fresh_db[\"docs\"].insert({\"id\": 1, \"title\": \"One\"}, ignore=True)\n+ assert table.last_rowid == 1\n+ assert table.last_pk == 1\n+ assert list(fresh_db[\"docs\"].rows_where(\"rowid = ?\", [table.last_rowid])) == [\n+ {\"id\": 1, \"title\": \"Exists\"}\n+ ]\n+\n+\n+@pytest.mark.parametrize(\"rowid_alias\", (\"rowid\", \"_rowid_\", \"oid\"))\n+@pytest.mark.parametrize(\"method\", (\"upsert\", \"insert_replace\", \"insert_ignore\"))\n+def test_pk_rowid_alias_on_rowid_table(fresh_db, rowid_alias, method):\n+ # rowid and its aliases are valid primary keys for a rowid table even\n+ # though they are not listed among the table's columns - see the Datasette\n+ # upsert API against tables without an explicit primary key\n+ fresh_db[\"t\"].insert({\"title\": \"Hello\"})\n+ assert fresh_db[\"t\"].pks == [\"rowid\"]\n+ record = {rowid_alias: 1, \"title\": \"Updated\"}\n+ if method == \"upsert\":\n+ table = fresh_db[\"t\"].upsert(record, pk=rowid_alias)\n+ elif method == \"insert_replace\":\n+ table = fresh_db[\"t\"].insert(record, pk=rowid_alias, replace=True)\n+ else:\n+ table = fresh_db[\"t\"].insert(record, pk=rowid_alias, ignore=True)\n+ assert table.last_pk == 1\n+ expected_title = \"Hello\" if method == \"insert_ignore\" else \"Updated\"\n+ assert list(fresh_db[\"t\"].rows) == [{\"title\": expected_title}]\n+\n+\n+def test_insert_ignore_reports_existing_row_compound_pk(fresh_db):\n+ # Compound primary key variant of the ignored-insert lookup\n+ fresh_db[\"t\"].insert_all([{\"a\": 1, \"b\": 2, \"note\": \"first\"}], pk=(\"a\", \"b\"))\n+ table = fresh_db[\"t\"].insert(\n+ {\"a\": 1, \"b\": 2, \"note\": \"second\"}, pk=(\"a\", \"b\"), ignore=True\n+ )\n+ assert table.last_pk == (1, 2)\n+ assert list(fresh_db[\"t\"].rows_where(\"rowid = ?\", [table.last_rowid])) == [\n+ {\"a\": 1, \"b\": 2, \"note\": \"first\"}\n+ ]\n+\n+\n+def test_insert_ignore_reports_existing_row_list_mode(fresh_db):\n+ # List-based iteration variant of the ignored-insert lookup\n+ fresh_db[\"t\"].insert_all([[\"id\", \"title\"], [1, \"first\"]], pk=\"id\")\n+ table = fresh_db[\"t\"].insert_all(\n+ [[\"id\", \"title\"], [1, \"second\"]], pk=\"id\", ignore=True\n+ )\n+ assert table.last_pk == 1\n+ assert table.last_rowid == 1\n+ assert list(fresh_db[\"t\"].rows) == [{\"id\": 1, \"title\": \"first\"}]\n+\n+\n+def test_insert_ignore_hash_id_reports_pk(fresh_db):\n+ # With hash_id the pk is the computed hash; the original record has no id\n+ # column to look up so last_rowid is left unset\n+ first = fresh_db[\"dogs\"].insert({\"name\": \"Cleo\"}, hash_id=\"id\")\n+ table = fresh_db[\"dogs\"].insert({\"name\": \"Cleo\"}, hash_id=\"id\", ignore=True)\n+ assert table.last_pk == first.last_pk\n+ assert table.last_rowid is None\n+ assert fresh_db[\"dogs\"].count == 1\n+\n+\n+def test_insert_ignore_unresolvable_conflict_leaves_pk_unset(fresh_db):\n+ # When the conflict cannot be resolved to a primary key lookup, last_pk and\n+ # last_rowid are left unset rather than reporting a misleading value\n+\n+ # rowid table with a UNIQUE column and no primary key: no pk to look up\n+ fresh_db[\"u\"].db.execute(\"create table u (title text unique)\")\n+ fresh_db[\"u\"].insert({\"title\": \"x\"})\n+ table = fresh_db[\"u\"].insert({\"title\": \"x\"}, ignore=True)\n+ assert table.last_pk is None\n+ assert table.last_rowid is None\n+ assert fresh_db[\"u\"].count == 1\n+\n+ # Conflict on a UNIQUE column other than the primary key: the pk value from\n+ # the record does not match the existing row, so the lookup finds nothing\n+ fresh_db[\"docs\"].db.execute(\n+ \"create table docs (id integer primary key, email text unique)\"\n+ )\n+ fresh_db[\"docs\"].insert({\"id\": 1, \"email\": \"a\"}, pk=\"id\")\n+ table = fresh_db[\"docs\"].insert({\"id\": 2, \"email\": \"a\"}, ignore=True)\n+ assert table.last_pk is None\n+ assert table.last_rowid is None\n+ assert fresh_db[\"docs\"].count == 1\n+\n+\n def test_insert_ignore_with_pk_after_other_table_insert(fresh_db):\n # https://github.com/simonw/sqlite-utils/issues/554\n user = {\"id\": \"abc\", \"name\": \"david\"}",
"comments": [
{
"url": "https://github.com/simonw/sqlite-utils/pull/782#issuecomment-4905266043",
"body": "## [Codecov](https://app.codecov.io/gh/simonw/sqlite-utils/pull/782?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison) Report\n:white_check_mark: All modified and coverable lines are covered by tests.\n:white_check_mark: Project coverage is 95.46%. Comparing base ([`d314d04`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/d314d04215f7337d42c847214861ec7ffe0bf757?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)) to head ([`d53e3cb`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/d53e3cbb8986222226820789e94a12d1d09c2861?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)).\n\n<details><summary>Additional details and impacted files</summary>\n\n\n\n```diff\n@@ Coverage Diff @@\n## main #782 +/- ##\n==========================================\n+ Coverage 95.15% 95.46% +0.31% \n==========================================\n Files 9 9 \n Lines 3712 3727 +15 \n==========================================\n+ Hits 3532 3558 +26 \n+ Misses 180 169 -11 \n```\n</details>\n\n[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/simonw/sqlite-utils/pull/782?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison). \n:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison).\n<details><summary> :rocket: New features to boost your workflow: </summary>\n\n- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.\n</details>",
"user": {
"login": "codecov[bot]",
"name": "codecov[bot]",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/in/254?v=4",
"id": 22429695
},
"id": 4905266043,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-07T15:05:30Z",
"updated_at": "2026-07-07T15:13:12Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<h2 dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/782?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Codecov</a> Report</h2>\n<p dir=\"auto\">✅ All modified and coverable lines are covered by tests.<br>\n✅ Project coverage is 95.46%. Comparing base (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/d314d04215f7337d42c847214861ec7ffe0bf757?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">d314d04</code></a>) to head (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/d53e3cbb8986222226820789e94a12d1d09c2861?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">d53e3cb</code></a>).</p>\n<details><summary>Additional details and impacted files</summary>\n<div class=\"highlight highlight-source-diff notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"@@ Coverage Diff @@\n## main #782 +/- ##\n==========================================\n+ Coverage 95.15% 95.46% +0.31% \n==========================================\n Files 9 9 \n Lines 3712 3727 +15 \n==========================================\n+ Hits 3532 3558 +26 \n+ Misses 180 169 -11 \"><pre class=\"notranslate\"><span class=\"pl-mdr\">@@ Coverage Diff @@</span>\n<span class=\"pl-c\"><span class=\"pl-c\">#</span># main #782 +/- ##</span>\n==========================================\n<span class=\"pl-mi1\"><span class=\"pl-mi1\">+</span> Coverage 95.15% 95.46% +0.31% </span>\n==========================================\n Files 9 9 \n Lines 3712 3727 +15 \n==========================================\n<span class=\"pl-mi1\"><span class=\"pl-mi1\">+</span> Hits 3532 3558 +26 </span>\n<span class=\"pl-mi1\"><span class=\"pl-mi1\">+</span> Misses 180 169 -11 </span></pre></div>\n</details>\n<p dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/782?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">☔ View full report in Codecov by Harness</a>.<br>\n📢 Have feedback on the report? <a href=\"https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Share it here</a>.</p>\n<details><summary> 🚀 New features to boost your workflow: </summary>\n<ul dir=\"auto\">\n<li>❄️ <a href=\"https://docs.codecov.com/docs/test-analytics\" rel=\"nofollow\">Test Analytics</a>: Detect flaky tests, report on failures, and find test suite problems.</li>\n</ul>\n</details>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/782#issuecomment-4905423952",
"body": "That last commit is because I prompted:\r\n\r\n> `The change in db.py is quite complex check if the tests fully cover that using coverage`",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4905423952,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-07T15:21:49Z",
"updated_at": "2026-07-07T15:21:49Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">That last commit is because I prompted:</p>\n<blockquote>\n<p dir=\"auto\"><code class=\"notranslate\">The change in db.py is quite complex check if the tests fully cover that using coverage</code></p>\n</blockquote>"
}
],
"created_at": "2026-07-07T15:02:04Z",
"updated_at": "2026-07-07T15:24:24Z",
"closed_at": "2026-07-07T15:24:24Z",
"merged_at": "2026-07-07T15:24:24Z",
"commits": 3,
"changed_files": 2,
"additions": 159,
"deletions": 28,
"display_url": "https://github.com/simonw/sqlite-utils/pull/782",
"display_title": "Fix rowid pk and last_rowid regressions in insert/upsert"
},
"url": "https://github.com/simonw/sqlite-utils/pull/782",
"title": "Fix rowid pk and last_rowid regressions in insert/upsert",
"diff": "@@ -53,6 +53,11 @@\n \n SQLITE_MAX_VARS = 999\n \n+# Names that refer to a rowid table's implicit integer primary key. These are\n+# valid primary key targets even though they are not listed among a table's\n+# columns. See https://www.sqlite.org/lang_createtable.html#rowid\n+ROWID_ALIASES = frozenset({\"rowid\", \"_rowid_\", \"oid\"})\n+\n _quote_fts_re = re.compile(r'\\s+|(\".*?\")')\n \n _virtual_table_using_re = re.compile(\n@@ -4343,10 +4348,14 @@ def insert_all(\n if pk and not hash_id and self.exists():\n pk_cols = [pk] if isinstance(pk, str) else list(pk)\n existing_columns = self.columns_dict\n+ # rowid and its aliases are valid primary keys for a rowid table\n+ # even though they are not listed among the table's columns\n+ rowid_aliases = ROWID_ALIASES if self.use_rowid else frozenset()\n missing_pk_cols = [\n col\n for col in pk_cols\n- if resolve_casing(col, existing_columns) not in existing_columns\n+ if col.lower() not in rowid_aliases\n+ and resolve_casing(col, existing_columns) not in existing_columns\n ]\n if missing_pk_cols:\n invalid_pk_error = InvalidColumns(\n@@ -4512,40 +4521,72 @@ def insert_all(\n if not upsert and result is not None:\n ignored_insert = ignore and result.rowcount == 0\n if ignored_insert:\n+ # The row was not inserted because it conflicts with an\n+ # existing row. Point last_pk / last_rowid at that existing\n+ # row when we can identify it from the record's primary key\n+ # values, rather than leaving them stale or unset.\n if list_mode:\n- first_record_list = cast(Sequence[Any], first_record)\n- if hash_id:\n- pass\n- elif isinstance(pk, str):\n- pk_index = column_names.index(\n- resolve_casing(pk, column_names)\n- )\n- self.last_pk = first_record_list[pk_index]\n- elif pk:\n- self.last_pk = tuple(\n- first_record_list[\n- column_names.index(resolve_casing(p, column_names))\n- ]\n- for p in pk\n- )\n+ first_record_dict = dict(\n+ zip(column_names, cast(Sequence[Any], first_record))\n+ )\n else:\n first_record_dict = cast(Dict[str, Any], first_record)\n- if hash_id:\n- self.last_pk = hash_record(\n- first_record_dict, hash_id_columns\n- )\n- elif isinstance(pk, str):\n- self.last_pk = first_record_dict[\n- resolve_casing(pk, first_record_dict)\n+ if hash_id:\n+ self.last_pk = hash_record(first_record_dict, hash_id_columns)\n+ elif isinstance(pk, str):\n+ self.last_pk = first_record_dict[\n+ resolve_casing(pk, first_record_dict)\n+ ]\n+ elif pk:\n+ self.last_pk = tuple(\n+ first_record_dict[resolve_casing(p, first_record_dict)]\n+ for p in pk\n+ )\n+ # Locate the existing conflicting row using its primary key\n+ # columns so we can report its rowid (and pk if not already\n+ # known). Falls back to leaving them unset if the conflict\n+ # cannot be resolved to a pk lookup (e.g. a UNIQUE column).\n+ key_cols: Optional[List[str]] = None\n+ if isinstance(pk, str):\n+ key_cols = [pk]\n+ elif pk:\n+ key_cols = list(pk)\n+ elif not hash_id and not self.use_rowid:\n+ key_cols = self.pks\n+ if key_cols:\n+ try:\n+ key_values = [\n+ first_record_dict[resolve_casing(c, first_record_dict)]\n+ for c in key_cols\n ]\n- elif pk:\n- self.last_pk = tuple(\n- first_record_dict[resolve_casing(p, first_record_dict)]\n- for p in pk\n+ except KeyError:\n+ key_values = None\n+ if key_values is not None:\n+ where = \" and \".join(\n+ \"{} = ?\".format(quote_identifier(c)) for c in key_cols\n )\n+ existing = self.db.execute(\n+ \"select rowid from {} where {} limit 1\".format(\n+ quote_identifier(self.name), where\n+ ),\n+ key_values,\n+ ).fetchone()\n+ if existing is not None:\n+ self.last_rowid = existing[0]\n+ # On a primary key conflict the record's pk\n+ # values identify the existing row\n+ if self.last_pk is None:\n+ self.last_pk = (\n+ key_values[0]\n+ if len(key_cols) == 1\n+ else tuple(key_values)\n+ )\n else:\n self.last_rowid = result.lastrowid\n- if (hash_id or pk) and self.last_rowid:\n+ # A rowid-alias pk resolves directly to the rowid, so there\n+ # is no separate pk column to look up\n+ rowid_pk = isinstance(pk, str) and pk.lower() in ROWID_ALIASES\n+ if (hash_id or (pk and not rowid_pk)) and self.last_rowid:\n # Set self.last_pk to the pk(s) for that rowid\n row = list(self.rows_where(\"rowid = ?\", [self.last_rowid]))[0]\n if hash_id:\n@@ -990,6 +990,96 @@ def test_insert_ignore(fresh_db):\n assert rows == [{\"id\": 1, \"bar\": 2}]\n \n \n+def test_insert_ignore_reports_existing_row(fresh_db):\n+ # An ignored insert (row already exists) should point last_rowid and\n+ # last_pk at the existing conflicting row - see the Datasette insert API\n+ fresh_db[\"docs\"].insert({\"id\": 1, \"title\": \"Exists\"}, pk=\"id\")\n+ # Insert a conflicting row with ignore=True and no explicit pk=\n+ table = fresh_db[\"docs\"].insert({\"id\": 1, \"title\": \"One\"}, ignore=True)\n+ assert table.last_rowid == 1\n+ assert table.last_pk == 1\n+ assert list(fresh_db[\"docs\"].rows_where(\"rowid = ?\", [table.last_rowid])) == [\n+ {\"id\": 1, \"title\": \"Exists\"}\n+ ]\n+\n+\n+@pytest.mark.parametrize(\"rowid_alias\", (\"rowid\", \"_rowid_\", \"oid\"))\n+@pytest.mark.parametrize(\"method\", (\"upsert\", \"insert_replace\", \"insert_ignore\"))\n+def test_pk_rowid_alias_on_rowid_table(fresh_db, rowid_alias, method):\n+ # rowid and its aliases are valid primary keys for a rowid table even\n+ # though they are not listed among the table's columns - see the Datasette\n+ # upsert API against tables without an explicit primary key\n+ fresh_db[\"t\"].insert({\"title\": \"Hello\"})\n+ assert fresh_db[\"t\"].pks == [\"rowid\"]\n+ record = {rowid_alias: 1, \"title\": \"Updated\"}\n+ if method == \"upsert\":\n+ table = fresh_db[\"t\"].upsert(record, pk=rowid_alias)\n+ elif method == \"insert_replace\":\n+ table = fresh_db[\"t\"].insert(record, pk=rowid_alias, replace=True)\n+ else:\n+ table = fresh_db[\"t\"].insert(record, pk=rowid_alias, ignore=True)\n+ assert table.last_pk == 1\n+ expected_title = \"Hello\" if method == \"insert_ignore\" else \"Updated\"\n+ assert list(fresh_db[\"t\"].rows) == [{\"title\": expected_title}]\n+\n+\n+def test_insert_ignore_reports_existing_row_compound_pk(fresh_db):\n+ # Compound primary key variant of the ignored-insert lookup\n+ fresh_db[\"t\"].insert_all([{\"a\": 1, \"b\": 2, \"note\": \"first\"}], pk=(\"a\", \"b\"))\n+ table = fresh_db[\"t\"].insert(\n+ {\"a\": 1, \"b\": 2, \"note\": \"second\"}, pk=(\"a\", \"b\"), ignore=True\n+ )\n+ assert table.last_pk == (1, 2)\n+ assert list(fresh_db[\"t\"].rows_where(\"rowid = ?\", [table.last_rowid])) == [\n+ {\"a\": 1, \"b\": 2, \"note\": \"first\"}\n+ ]\n+\n+\n+def test_insert_ignore_reports_existing_row_list_mode(fresh_db):\n+ # List-based iteration variant of the ignored-insert lookup\n+ fresh_db[\"t\"].insert_all([[\"id\", \"title\"], [1, \"first\"]], pk=\"id\")\n+ table = fresh_db[\"t\"].insert_all(\n+ [[\"id\", \"title\"], [1, \"second\"]], pk=\"id\", ignore=True\n+ )\n+ assert table.last_pk == 1\n+ assert table.last_rowid == 1\n+ assert list(fresh_db[\"t\"].rows) == [{\"id\": 1, \"title\": \"first\"}]\n+\n+\n+def test_insert_ignore_hash_id_reports_pk(fresh_db):\n+ # With hash_id the pk is the computed hash; the original record has no id\n+ # column to look up so last_rowid is left unset\n+ first = fresh_db[\"dogs\"].insert({\"name\": \"Cleo\"}, hash_id=\"id\")\n+ table = fresh_db[\"dogs\"].insert({\"name\": \"Cleo\"}, hash_id=\"id\", ignore=True)\n+ assert table.last_pk == first.last_pk\n+ assert table.last_rowid is None\n+ assert fresh_db[\"dogs\"].count == 1\n+\n+\n+def test_insert_ignore_unresolvable_conflict_leaves_pk_unset(fresh_db):\n+ # When the conflict cannot be resolved to a primary key lookup, last_pk and\n+ # last_rowid are left unset rather than reporting a misleading value\n+\n+ # rowid table with a UNIQUE column and no primary key: no pk to look up\n+ fresh_db[\"u\"].db.execute(\"create table u (title text unique)\")\n+ fresh_db[\"u\"].insert({\"title\": \"x\"})\n+ table = fresh_db[\"u\"].insert({\"title\": \"x\"}, ignore=True)\n+ assert table.last_pk is None\n+ assert table.last_rowid is None\n+ assert fresh_db[\"u\"].count == 1\n+\n+ # Conflict on a UNIQUE column other than the primary key: the pk value from\n+ # the record does not match the existing row, so the lookup finds nothing\n+ fresh_db[\"docs\"].db.execute(\n+ \"create table docs (id integer primary key, email text unique)\"\n+ )\n+ fresh_db[\"docs\"].insert({\"id\": 1, \"email\": \"a\"}, pk=\"id\")\n+ table = fresh_db[\"docs\"].insert({\"id\": 2, \"email\": \"a\"}, ignore=True)\n+ assert table.last_pk is None\n+ assert table.last_rowid is None\n+ assert fresh_db[\"docs\"].count == 1\n+\n+\n def test_insert_ignore_with_pk_after_other_table_insert(fresh_db):\n # https://github.com/simonw/sqlite-utils/issues/554\n user = {\"id\": \"abc\", \"name\": \"david\"}",
"comments": [
{
"url": "https://github.com/simonw/sqlite-utils/pull/782#issuecomment-4905266043",
"body": "## [Codecov](https://app.codecov.io/gh/simonw/sqlite-utils/pull/782?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison) Report\n:white_check_mark: All modified and coverable lines are covered by tests.\n:white_check_mark: Project coverage is 95.46%. Comparing base ([`d314d04`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/d314d04215f7337d42c847214861ec7ffe0bf757?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)) to head ([`d53e3cb`](https://app.codecov.io/gh/simonw/sqlite-utils/commit/d53e3cbb8986222226820789e94a12d1d09c2861?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison)).\n\n<details><summary>Additional details and impacted files</summary>\n\n\n\n```diff\n@@ Coverage Diff @@\n## main #782 +/- ##\n==========================================\n+ Coverage 95.15% 95.46% +0.31% \n==========================================\n Files 9 9 \n Lines 3712 3727 +15 \n==========================================\n+ Hits 3532 3558 +26 \n+ Misses 180 169 -11 \n```\n</details>\n\n[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/simonw/sqlite-utils/pull/782?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison). \n:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison).\n<details><summary> :rocket: New features to boost your workflow: </summary>\n\n- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.\n</details>",
"user": {
"login": "codecov[bot]",
"name": "codecov[bot]",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/in/254?v=4",
"id": 22429695
},
"id": 4905266043,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-07T15:05:30Z",
"updated_at": "2026-07-07T15:13:12Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<h2 dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/782?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Codecov</a> Report</h2>\n<p dir=\"auto\">✅ All modified and coverable lines are covered by tests.<br>\n✅ Project coverage is 95.46%. Comparing base (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/d314d04215f7337d42c847214861ec7ffe0bf757?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">d314d04</code></a>) to head (<a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/commit/d53e3cbb8986222226820789e94a12d1d09c2861?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\"><code class=\"notranslate\">d53e3cb</code></a>).</p>\n<details><summary>Additional details and impacted files</summary>\n<div class=\"highlight highlight-source-diff notranslate position-relative overflow-auto\" dir=\"auto\" data-snippet-clipboard-copy-content=\"@@ Coverage Diff @@\n## main #782 +/- ##\n==========================================\n+ Coverage 95.15% 95.46% +0.31% \n==========================================\n Files 9 9 \n Lines 3712 3727 +15 \n==========================================\n+ Hits 3532 3558 +26 \n+ Misses 180 169 -11 \"><pre class=\"notranslate\"><span class=\"pl-mdr\">@@ Coverage Diff @@</span>\n<span class=\"pl-c\"><span class=\"pl-c\">#</span># main #782 +/- ##</span>\n==========================================\n<span class=\"pl-mi1\"><span class=\"pl-mi1\">+</span> Coverage 95.15% 95.46% +0.31% </span>\n==========================================\n Files 9 9 \n Lines 3712 3727 +15 \n==========================================\n<span class=\"pl-mi1\"><span class=\"pl-mi1\">+</span> Hits 3532 3558 +26 </span>\n<span class=\"pl-mi1\"><span class=\"pl-mi1\">+</span> Misses 180 169 -11 </span></pre></div>\n</details>\n<p dir=\"auto\"><a href=\"https://app.codecov.io/gh/simonw/sqlite-utils/pull/782?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">☔ View full report in Codecov by Harness</a>.<br>\n📢 Have feedback on the report? <a href=\"https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Simon+Willison\" rel=\"nofollow\">Share it here</a>.</p>\n<details><summary> 🚀 New features to boost your workflow: </summary>\n<ul dir=\"auto\">\n<li>❄️ <a href=\"https://docs.codecov.com/docs/test-analytics\" rel=\"nofollow\">Test Analytics</a>: Detect flaky tests, report on failures, and find test suite problems.</li>\n</ul>\n</details>"
},
{
"url": "https://github.com/simonw/sqlite-utils/pull/782#issuecomment-4905423952",
"body": "That last commit is because I prompted:\r\n\r\n> `The change in db.py is quite complex check if the tests fully cover that using coverage`",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4905423952,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-07T15:21:49Z",
"updated_at": "2026-07-07T15:21:49Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">That last commit is because I prompted:</p>\n<blockquote>\n<p dir=\"auto\"><code class=\"notranslate\">The change in db.py is quite complex check if the tests fully cover that using coverage</code></p>\n</blockquote>"
}
],
"display_url": "https://github.com/simonw/sqlite-utils/pull/782",
"display_title": "Fix rowid pk and last_rowid regressions in insert/upsert"
}MCP tool call
codex_apps.github.fetch_pr
{
"repo_full_name": "simonw/sqlite-utils",
"pr_number": 780
}Action completed.
{
"pull_request": {
"url": "https://github.com/simonw/sqlite-utils/pull/780",
"number": 780,
"state": "closed",
"merged": false,
"mergeable": true,
"draft": false,
"body": "## Summary\n\nQuality: Built-in name shadowing in `jsonsplit` signature\n\n## Problem\n\n**Severity**: `Medium` | **File**: `sqlite_utils/recipes.py:L55`\n\nThe `jsonsplit` function in `recipes.py` uses `type` as a parameter name, shadowing the Python built-in `type`. This can cause subtle bugs if the built-in is needed within the function scope or in nested calls, and it reduces code clarity for readers.\n\n## Solution\n\nRename the parameter to `type_` or `value_type` to avoid shadowing the built-in `type`. Update all call sites accordingly.\n\n## Changes\n\n- `sqlite_utils/recipes.py` (modified)\r\n\r\n<!-- readthedocs-preview sqlite-utils start -->\r\n----\n📚 Documentation preview 📚: https://sqlite-utils--780.org.readthedocs.build/en/780/\n\r\n<!-- readthedocs-preview sqlite-utils end -->",
"title": "Quality: Built-in name shadowing in `jsonsplit` signature",
"base": "main",
"base_sha": "d314d04215f7337d42c847214861ec7ffe0bf757",
"head": "improve/quality/built-in-name-shadowing-in-jsonsplit-sig",
"head_sha": "49499339179943c3342a81b22a3969c9ed3daaa8",
"merge_commit_sha": "f5e381ff68cebabf51fa65c01efece1e5acb9e0b",
"user": {
"login": "tomaioo",
"name": "tomaioo",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/203048277?v=4",
"id": 203048277
},
"requested_reviewers": null,
"requested_team_reviewers": null,
"diff": "@@ -68,9 +68,9 @@ def parsedatetime(\n \n \n def jsonsplit(\n- value: str, delimiter: str = \",\", type: Callable[[str], object] = str\n+ value: str, delimiter: str = \",\", type_: Callable[[str], object] = str\n ) -> str:\n \"\"\"\n Convert a string like a,b,c into a JSON array [\"a\", \"b\", \"c\"]\n \"\"\"\n- return json.dumps([type(s.strip()) for s in value.split(delimiter)])\n+ return json.dumps([type_(s.strip()) for s in value.split(delimiter)])",
"comments": [
{
"url": "https://github.com/simonw/sqlite-utils/pull/780#issuecomment-4907606790",
"body": "I'm willing to take this risk. If I need to use `type()` inside that function later on I'll address the problem then.",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4907606790,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-07T19:13:11Z",
"updated_at": "2026-07-07T19:13:11Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">I'm willing to take this risk. If I need to use <code class=\"notranslate\">type()</code> inside that function later on I'll address the problem then.</p>"
}
],
"created_at": "2026-07-07T06:10:16Z",
"updated_at": "2026-07-07T19:13:11Z",
"closed_at": "2026-07-07T19:13:11Z",
"merged_at": null,
"commits": 1,
"changed_files": 1,
"additions": 2,
"deletions": 2,
"display_url": "https://github.com/simonw/sqlite-utils/pull/780",
"display_title": "Quality: Built-in name shadowing in `jsonsplit` signature"
},
"url": "https://github.com/simonw/sqlite-utils/pull/780",
"title": "Quality: Built-in name shadowing in `jsonsplit` signature",
"diff": "@@ -68,9 +68,9 @@ def parsedatetime(\n \n \n def jsonsplit(\n- value: str, delimiter: str = \",\", type: Callable[[str], object] = str\n+ value: str, delimiter: str = \",\", type_: Callable[[str], object] = str\n ) -> str:\n \"\"\"\n Convert a string like a,b,c into a JSON array [\"a\", \"b\", \"c\"]\n \"\"\"\n- return json.dumps([type(s.strip()) for s in value.split(delimiter)])\n+ return json.dumps([type_(s.strip()) for s in value.split(delimiter)])",
"comments": [
{
"url": "https://github.com/simonw/sqlite-utils/pull/780#issuecomment-4907606790",
"body": "I'm willing to take this risk. If I need to use `type()` inside that function later on I'll address the problem then.",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"id": 4907606790,
"pull_request_review_id": null,
"in_reply_to_id": null,
"created_at": "2026-07-07T19:13:11Z",
"updated_at": "2026-07-07T19:13:11Z",
"review": null,
"side": null,
"line": null,
"start_line": null,
"path": null,
"body_html": "<p dir=\"auto\">I'm willing to take this risk. If I need to use <code class=\"notranslate\">type()</code> inside that function later on I'll address the problem then.</p>"
}
],
"display_url": "https://github.com/simonw/sqlite-utils/pull/780",
"display_title": "Quality: Built-in name shadowing in `jsonsplit` signature"
}MCP tool call
codex_apps.github.fetch_pr
{
"repo_full_name": "simonw/sqlite-utils",
"pr_number": 779
}Action completed.
{
"pull_request": {
"url": "https://github.com/simonw/sqlite-utils/pull/779",
"number": 779,
"state": "closed",
"merged": true,
"mergeable": false,
"draft": false,
"body": "See:\r\n- https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4900034150\r\n\r\n<!-- readthedocs-preview sqlite-utils start -->\r\n----\n📚 Documentation preview 📚: https://sqlite-utils--779.org.readthedocs.build/en/779/\n\r\n<!-- readthedocs-preview sqlite-utils end -->",
"title": "Fixes for final review in issue #769",
"base": "main",
"base_sha": "adc10df98102c76c86c77108615792ba238a0ae3",
"head": "issue-769-review-fixes",
"head_sha": "25824467846ca75db3bd737cb19b5a8e0d01b1b2",
"merge_commit_sha": "e5c772823f6073c81b3229a8990564f46bf40a4a",
"user": {
"login": "simonw",
"name": "simonw",
"email": null,
"avatar_url": "https://avatars.githubusercontent.com/u/9599?v=4",
"id": 9599
},
"requested_reviewers": null,
"requested_team_reviewers": null,
"diff": "@@ -18,6 +18,20 @@ Unreleased\n - Fixed an ``IndexError`` from ``table.insert(..., pk=..., ignore=True)`` when an ignored insert followed writes to another table on the same connection. ``last_pk`` is now populated from the explicit primary key value instead of looking up a stale ``lastrowid``. (:issue:`554`)\n - Fixed a bug where a failed write statement executed with ``db.execute()`` left the driver's implicit transaction open. Every subsequent write then joined that phantom transaction, which nothing committed, so their work was silently rolled back when the connection was closed. The implicit transaction opened by a failed statement is now rolled back before the exception is raised. A failed write inside a transaction opened with ``db.begin()`` or ``db.atomic()`` leaves that transaction open and untouched, as before.\n - Fixed a bug where transaction-control statements prefixed with an empty statement - ``db.query(\"; COMMIT\")`` - or a UTF-8 byte order mark slipped past the check that rejects them, committing the caller's open transaction before raising a confusing ``OperationalError``. The keyword scanner used by ``db.query()`` and ``db.execute()`` now skips leading ``;`` and byte order marks, matching what the ``sqlite3`` driver tolerates before the first token, so these statements are rejected with a ``ValueError`` without being executed. The same fix means ``db.execute(\"; BEGIN\")`` no longer auto-commits the transaction it just opened.\n+- Documented a limitation of ``db.query()``: a ``PRAGMA`` statement that returns no rows raises a ``ValueError`` but still takes effect, because PRAGMA statements run outside the savepoint guard used to roll back other rejected statements. Use ``db.execute()`` for row-less PRAGMA statements.\n+- Fixed exception masking when a statement destroys the enclosing transaction. An error such as a ``RAISE(ROLLBACK)`` trigger or ``INSERT OR ROLLBACK`` conflict rolls back the whole transaction, destroying every savepoint - the cleanup in ``db.atomic()`` and ``db.query()`` then failed with ``OperationalError: no such savepoint`` (or ``cannot rollback - no transaction is active``), hiding the original ``IntegrityError`` from code that tried to catch it. Cleanup now checks whether a transaction is still open first, so the original exception propagates.\n+- ``sqlite-utils migrate --list`` is now read-only even when the migrations file uses the legacy ``sqlite_migrate.Migrations`` class, whose listing methods create the ``_sqlite_migrations`` table as a side effect. The listing now runs inside a transaction that is rolled back.\n+- ``sqlite-utils insert ... --pk <missing column>`` and ``sqlite-utils extract <missing column>`` now show a clean ``Error:`` message instead of a raw Python traceback. The ``extract`` command also shows a clean error when pointed at a view.\n+- Fixed a bug where running ``table.extract()`` more than once against the same lookup table inserted duplicate rows for values containing ``null`` - SQLite unique indexes treat ``NULL`` values as distinct, so ``INSERT OR IGNORE`` alone could not dedupe them. Each repeat extract added another copy that nothing referenced. The insert now uses an ``IS``-based ``NOT EXISTS`` guard so ``null``-containing rows match existing lookup rows.\n+- ``db.add_foreign_keys()`` no longer silently ignores requested ``ON DELETE``/``ON UPDATE`` actions when a foreign key with the same columns already exists - it raises ``AlterError`` suggesting ``table.transform()``, since the actions of an existing foreign key cannot be changed in place. Exact duplicates, including actions, are still skipped so repeated calls stay idempotent. The method also now validates that compound foreign keys have the same number of columns on both sides, instead of silently discarding the extra columns.\n+- ``db.ensure_autocommit_on()`` now raises ``TransactionError`` if called while a transaction is open. Assigning ``isolation_level`` commits any pending transaction as a side effect, so entering the block silently committed the caller's open transaction and made a later ``rollback()`` a no-op.\n+- ``sqlite-utils migrate --stop-before`` now exits with an error if the named migration has already been applied. Previously the name passed validation but was only checked against pending migrations, so every migration after it was silently applied - the exact outcome ``--stop-before`` exists to prevent. ``Migrations.apply(db, stop_before=...)`` raises ``ValueError`` in the same situation, before applying anything.\n+- Fixed a regression where ``table.insert(..., pk=..., alter=True)`` raised ``InvalidColumns`` if the primary key column did not exist in the table yet. With ``alter=True`` the check now waits until the record keys are known, so a pk column supplied by the records is added by the alter as it was in 3.x. A pk column found in neither the table nor the records still raises ``InvalidColumns``.\n+- Fixed a bug where inserting CSV or TSV data into an existing table rewrote that table's column types to match the incoming file. Type detection is the default in 4.0, so ``sqlite-utils insert data.db places places.csv --csv`` against a table with a ``TEXT`` zip code column would convert the column to ``INTEGER`` and corrupt values with leading zeros - ``\"01234\"`` became ``1234``. Detected types are now only applied when the ``insert`` or ``upsert`` command creates the table.\n+- Fixed ``pks_and_rows_where()`` raising ``AttributeError`` when called on a view, and no longer double-quotes the synthesized ``rowid`` column in its generated SQL - SQLite turns a double-quoted identifier that does not resolve into a string literal, which on a view produced a confusing ``KeyError`` instead of the ``OperationalError`` raised in 3.x. Compound primary keys returned by this method now follow ``PRIMARY KEY`` declaration order.\n+- The ``foreign_keys=`` argument to ``create()`` and ``insert()`` accepts a mixed list of ``ForeignKey`` objects, tuples and column name strings again. In 4.0 pre-releases mixing ``ForeignKey`` objects with tuples raised a ``ValueError`` - a regression from 3.x, where ``ForeignKey`` was a ``namedtuple`` and passed the tuple checks.\n+- ``ForeignKey`` objects are hashable again. The 4.0 change from ``namedtuple`` to dataclass accidentally made them unhashable, breaking patterns like ``set(table.foreign_keys)`` that worked in 3.x. ``ForeignKey`` is now a frozen dataclass - immutable and hashable, like the namedtuple was.\n+- Fixed a bug where compound primary key columns were returned in table column order instead of ``PRIMARY KEY`` declaration order. For a table declared as ``CREATE TABLE other (b TEXT, a TEXT, PRIMARY KEY (a, b))`` an implicit ``FOREIGN KEY (x, y) REFERENCES other`` was introspected as referencing ``(b, a)`` when SQLite resolves it as ``(a, b)`` - running ``transform()`` on such a table then rewrote the schema with the inverted column order, silently reversing the meaning of the constraint and causing foreign key errors on valid data. ``table.pks``, compound foreign key guessing and ``transform()`` now all use the primary key declaration order, and ``transform()`` no longer reorders a compound ``PRIMARY KEY (b, a)`` into table column order.\n \n .. _v4_0rc3:\n \n@@ -1326,6 +1326,8 @@ A progre