From 60811e730509667f702bc08f9bf5fc3fe13b7f45 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 7 Jul 2026 08:24:24 -0700 Subject: [PATCH 01/26] Fix rowid pk and last_rowid regressions in insert/upsert Closes #781, #783 Refs https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4900618685 * Fix rowid pk and last_rowid regressions in insert/upsert Two behaviour regressions in the 4.0 insert/upsert rewrite broke callers (notably Datasette's write API) that operate on tables without an explicit primary key. Both are fixed here with regression tests. 1. rowid (and its aliases _rowid_/oid) were rejected as a primary key. Table.pks already reports ["rowid"] for a rowid table, but the new pk validation raised InvalidColumns because rowid is not listed among the table's columns, and the insert success path then raised KeyError when looking up the pk value. rowid aliases are now accepted for rowid tables and resolve directly to the rowid. 2. An ignored insert (INSERT OR IGNORE that matched an existing row) no longer populated last_rowid, and only set last_pk when an explicit pk= was passed. It now locates the existing conflicting row by its primary key values and reports that row's rowid and pk, rather than relying on the connection's last inserted rowid. Add a shared ROWID_ALIASES constant for the rowid alias names. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E7af8SxFZqiCerJB6MqKnY --- sqlite_utils/db.py | 97 +++++++++++++++++++++++++++++++------------- tests/test_create.py | 90 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 28 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index bec68fc..3033a36 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -53,6 +53,11 @@ except ImportError: SQLITE_MAX_VARS = 999 +# Names that refer to a rowid table's implicit integer primary key. These are +# valid primary key targets even though they are not listed among a table's +# columns. See https://www.sqlite.org/lang_createtable.html#rowid +ROWID_ALIASES = frozenset({"rowid", "_rowid_", "oid"}) + _quote_fts_re = re.compile(r'\s+|(".*?")') _virtual_table_using_re = re.compile( @@ -4343,10 +4348,14 @@ class Table(Queryable): if pk and not hash_id and self.exists(): pk_cols = [pk] if isinstance(pk, str) else list(pk) existing_columns = self.columns_dict + # rowid and its aliases are valid primary keys for a rowid table + # even though they are not listed among the table's columns + rowid_aliases = ROWID_ALIASES if self.use_rowid else frozenset() missing_pk_cols = [ col for col in pk_cols - if resolve_casing(col, existing_columns) not in existing_columns + if col.lower() not in rowid_aliases + and resolve_casing(col, existing_columns) not in existing_columns ] if missing_pk_cols: invalid_pk_error = InvalidColumns( @@ -4512,40 +4521,72 @@ class Table(Queryable): if not upsert and result is not None: ignored_insert = ignore and result.rowcount == 0 if ignored_insert: + # The row was not inserted because it conflicts with an + # existing row. Point last_pk / last_rowid at that existing + # row when we can identify it from the record's primary key + # values, rather than leaving them stale or unset. if list_mode: - first_record_list = cast(Sequence[Any], first_record) - if hash_id: - pass - elif isinstance(pk, str): - pk_index = column_names.index( - resolve_casing(pk, column_names) - ) - self.last_pk = first_record_list[pk_index] - elif pk: - self.last_pk = tuple( - first_record_list[ - column_names.index(resolve_casing(p, column_names)) - ] - for p in pk - ) + first_record_dict = dict( + zip(column_names, cast(Sequence[Any], first_record)) + ) else: first_record_dict = cast(Dict[str, Any], first_record) - if hash_id: - self.last_pk = hash_record( - first_record_dict, hash_id_columns - ) - elif isinstance(pk, str): - self.last_pk = first_record_dict[ - resolve_casing(pk, first_record_dict) + if hash_id: + self.last_pk = hash_record(first_record_dict, hash_id_columns) + elif isinstance(pk, str): + self.last_pk = first_record_dict[ + resolve_casing(pk, first_record_dict) + ] + elif pk: + self.last_pk = tuple( + first_record_dict[resolve_casing(p, first_record_dict)] + for p in pk + ) + # Locate the existing conflicting row using its primary key + # columns so we can report its rowid (and pk if not already + # known). Falls back to leaving them unset if the conflict + # cannot be resolved to a pk lookup (e.g. a UNIQUE column). + key_cols: Optional[List[str]] = None + if isinstance(pk, str): + key_cols = [pk] + elif pk: + key_cols = list(pk) + elif not hash_id and not self.use_rowid: + key_cols = self.pks + if key_cols: + try: + key_values = [ + first_record_dict[resolve_casing(c, first_record_dict)] + for c in key_cols ] - elif pk: - self.last_pk = tuple( - first_record_dict[resolve_casing(p, first_record_dict)] - for p in pk + except KeyError: + key_values = None + if key_values is not None: + where = " and ".join( + "{} = ?".format(quote_identifier(c)) for c in key_cols ) + existing = self.db.execute( + "select rowid from {} where {} limit 1".format( + quote_identifier(self.name), where + ), + key_values, + ).fetchone() + if existing is not None: + self.last_rowid = existing[0] + # On a primary key conflict the record's pk + # values identify the existing row + if self.last_pk is None: + self.last_pk = ( + key_values[0] + if len(key_cols) == 1 + else tuple(key_values) + ) else: self.last_rowid = result.lastrowid - if (hash_id or pk) and self.last_rowid: + # A rowid-alias pk resolves directly to the rowid, so there + # is no separate pk column to look up + rowid_pk = isinstance(pk, str) and pk.lower() in ROWID_ALIASES + if (hash_id or (pk and not rowid_pk)) and self.last_rowid: # Set self.last_pk to the pk(s) for that rowid row = list(self.rows_where("rowid = ?", [self.last_rowid]))[0] if hash_id: diff --git a/tests/test_create.py b/tests/test_create.py index 42fa1c4..7fab5e6 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -990,6 +990,96 @@ def test_insert_ignore(fresh_db): assert rows == [{"id": 1, "bar": 2}] +def test_insert_ignore_reports_existing_row(fresh_db): + # An ignored insert (row already exists) should point last_rowid and + # last_pk at the existing conflicting row - see the Datasette insert API + fresh_db["docs"].insert({"id": 1, "title": "Exists"}, pk="id") + # Insert a conflicting row with ignore=True and no explicit pk= + table = fresh_db["docs"].insert({"id": 1, "title": "One"}, ignore=True) + assert table.last_rowid == 1 + assert table.last_pk == 1 + assert list(fresh_db["docs"].rows_where("rowid = ?", [table.last_rowid])) == [ + {"id": 1, "title": "Exists"} + ] + + +@pytest.mark.parametrize("rowid_alias", ("rowid", "_rowid_", "oid")) +@pytest.mark.parametrize("method", ("upsert", "insert_replace", "insert_ignore")) +def test_pk_rowid_alias_on_rowid_table(fresh_db, rowid_alias, method): + # rowid and its aliases are valid primary keys for a rowid table even + # though they are not listed among the table's columns - see the Datasette + # upsert API against tables without an explicit primary key + fresh_db["t"].insert({"title": "Hello"}) + assert fresh_db["t"].pks == ["rowid"] + record = {rowid_alias: 1, "title": "Updated"} + if method == "upsert": + table = fresh_db["t"].upsert(record, pk=rowid_alias) + elif method == "insert_replace": + table = fresh_db["t"].insert(record, pk=rowid_alias, replace=True) + else: + table = fresh_db["t"].insert(record, pk=rowid_alias, ignore=True) + assert table.last_pk == 1 + expected_title = "Hello" if method == "insert_ignore" else "Updated" + assert list(fresh_db["t"].rows) == [{"title": expected_title}] + + +def test_insert_ignore_reports_existing_row_compound_pk(fresh_db): + # Compound primary key variant of the ignored-insert lookup + fresh_db["t"].insert_all([{"a": 1, "b": 2, "note": "first"}], pk=("a", "b")) + table = fresh_db["t"].insert( + {"a": 1, "b": 2, "note": "second"}, pk=("a", "b"), ignore=True + ) + assert table.last_pk == (1, 2) + assert list(fresh_db["t"].rows_where("rowid = ?", [table.last_rowid])) == [ + {"a": 1, "b": 2, "note": "first"} + ] + + +def test_insert_ignore_reports_existing_row_list_mode(fresh_db): + # List-based iteration variant of the ignored-insert lookup + fresh_db["t"].insert_all([["id", "title"], [1, "first"]], pk="id") + table = fresh_db["t"].insert_all( + [["id", "title"], [1, "second"]], pk="id", ignore=True + ) + assert table.last_pk == 1 + assert table.last_rowid == 1 + assert list(fresh_db["t"].rows) == [{"id": 1, "title": "first"}] + + +def test_insert_ignore_hash_id_reports_pk(fresh_db): + # With hash_id the pk is the computed hash; the original record has no id + # column to look up so last_rowid is left unset + first = fresh_db["dogs"].insert({"name": "Cleo"}, hash_id="id") + table = fresh_db["dogs"].insert({"name": "Cleo"}, hash_id="id", ignore=True) + assert table.last_pk == first.last_pk + assert table.last_rowid is None + assert fresh_db["dogs"].count == 1 + + +def test_insert_ignore_unresolvable_conflict_leaves_pk_unset(fresh_db): + # When the conflict cannot be resolved to a primary key lookup, last_pk and + # last_rowid are left unset rather than reporting a misleading value + + # rowid table with a UNIQUE column and no primary key: no pk to look up + fresh_db["u"].db.execute("create table u (title text unique)") + fresh_db["u"].insert({"title": "x"}) + table = fresh_db["u"].insert({"title": "x"}, ignore=True) + assert table.last_pk is None + assert table.last_rowid is None + assert fresh_db["u"].count == 1 + + # Conflict on a UNIQUE column other than the primary key: the pk value from + # the record does not match the existing row, so the lookup finds nothing + fresh_db["docs"].db.execute( + "create table docs (id integer primary key, email text unique)" + ) + fresh_db["docs"].insert({"id": 1, "email": "a"}, pk="id") + table = fresh_db["docs"].insert({"id": 2, "email": "a"}, ignore=True) + assert table.last_pk is None + assert table.last_rowid is None + assert fresh_db["docs"].count == 1 + + def test_insert_ignore_with_pk_after_other_table_insert(fresh_db): # https://github.com/simonw/sqlite-utils/issues/554 user = {"id": "abc", "name": "david"} From 8bc9213a8e9c52a2d46ffc14da72fcd5af276840 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 7 Jul 2026 08:40:31 -0700 Subject: [PATCH 02/26] Release 4.0 Refs #781, #783, #769 --- docs/changelog.rst | 31 +++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0943dca..9623ec2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,37 @@ Changelog =========== +.. _v4_0: + +4.0 (2026-07-07) +---------------- + +The 4.0 release includes some minor backwards-incompatible fixes (hence the major version number bump) and introduces three major new features: + +- :ref:`Database migrations `, providing a structured mechanism for evolving a project's schema over time. (:issue:`752`) +- :ref:`Nested transaction support ` via ``db.atomic()``, plus numerous improvements to how transactions work across the library. (:issue:`755`) +- Support for :ref:`compound foreign keys `, including creation, transformation and introspection through :ref:`table.foreign_keys `. (:issue:`594`) + +Other notable changes include: + +- Upserts now use SQLite's ``INSERT ... ON CONFLICT ... DO UPDATE SET`` syntax, detect existing table primary keys automatically and reject records that are missing required primary key values. (:issue:`652`) +- ``db.query()`` now executes immediately and rejects statements that do not return rows; use ``db.execute()`` for writes and DDL. +- CSV and TSV imports now detect column types by default, while inserts into existing tables preserve those tables' column types. (:issue:`679`) +- Foreign key handling now preserves ``ON DELETE``/``ON UPDATE`` actions during transforms and resolves referenced primary keys more accurately. (:issue:`530`) +- Column names passed to Python API methods are now matched case-insensitively, mirroring SQLite's own identifier behavior. (:issue:`760`) +- The command-line tool now emits UTF-8 JSON output by default, with ``--ascii`` available to restore escaped output. (:issue:`625`) +- ``table.extract()`` and ``extracts=`` no longer create lookup table records for all-``null`` values. (:issue:`186`) + +See :ref:`upgrading_3_to_4` for details on backwards-incompatible changes. + +The detailed release notes for the features and fixes shipped during the 4.0 pre-release cycle are available in :ref:`4.0a0 `, :ref:`4.0a1 `, :ref:`4.0rc1 `, :ref:`4.0rc2 `, :ref:`4.0rc3 ` and :ref:`4.0rc4 `. + +Bug fixes since 4.0rc4 +~~~~~~~~~~~~~~~~~~~~~~ + +- Fixed 4.0 regressions in ``insert``/``upsert`` against tables that use SQLite's implicit ``rowid`` primary key. Passing ``pk="rowid"``, ``pk="_rowid_"`` or ``pk="oid"`` now works again for rowid tables, and ``last_pk`` is set correctly. (:issue:`781`) +- Fixed ``insert(..., ignore=True)`` and ``insert_all(..., ignore=True)`` so an ignored insert that conflicts with an existing primary key row now reports that existing row in ``last_rowid`` and ``last_pk`` where possible. This also works for compound primary keys and list-mode inserts. (:issue:`783`) + .. _v4_0rc4: 4.0rc4 (2026-07-06) diff --git a/pyproject.toml b/pyproject.toml index c1024c2..a3a42a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sqlite-utils" -version = "4.0rc4" +version = "4.0" description = "CLI tool and Python library for manipulating SQLite databases" readme = { file = "README.md", content-type = "text/markdown" } authors = [ From 353baf280d3765b9680a49276945a0e96bc9e238 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 7 Jul 2026 11:51:53 -0700 Subject: [PATCH 03/26] Corrected imports in migrations docs --- docs/migrations.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/migrations.rst b/docs/migrations.rst index cfdbf13..23aa9d8 100644 --- a/docs/migrations.rst +++ b/docs/migrations.rst @@ -27,7 +27,7 @@ Here is a simple example of a ``migrations.py`` file which creates a table, then .. code-block:: python - from sqlite_utils import Database, Migrations + from sqlite_utils import Migrations migrations = Migrations("creatures") @@ -51,6 +51,8 @@ Once you have a ``Migrations(name)`` collection with one or more migrations regi .. code-block:: python + from sqlite_utils import Database + db = Database("creatures.db") migrations.apply(db) From 619770bf427e47657099f1a4fdaa8777ec4c68d8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 7 Jul 2026 17:52:26 -0700 Subject: [PATCH 04/26] sqlite-utils query - to read SQL from stdin, closes #765 --- docs/changelog.rst | 7 +++++++ docs/cli-reference.rst | 4 ++++ docs/cli.rst | 10 ++++++++++ sqlite_utils/cli.py | 8 ++++++++ tests/test_cli.py | 19 +++++++++++++++++++ 5 files changed, 48 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9623ec2..cf9486a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,13 @@ Changelog =========== +.. _v_unreleased: + +Unreleased +---------- + +- ``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`) + .. _v4_0: 4.0 (2026-07-07) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 49eba52..000f88f 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -109,6 +109,10 @@ See :ref:`cli_query`. "select * from chickens where age > :age" \ -p age 1 + Pass "-" as the SQL to read the query from standard input: + + echo "select * from chickens" | sqlite-utils data.db - + Options: --attach ... Additional databases to attach - specify alias and filepath diff --git a/docs/cli.rst b/docs/cli.rst index 063e80f..bbbbf03 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -29,6 +29,16 @@ The ``sqlite-utils query`` command lets you run queries directly against a SQLit .. note:: In Python: :ref:`db.query() ` CLI reference: :ref:`sqlite-utils query ` +Pass ``-`` as the SQL query to read the query from standard input. This is useful for longer queries that would otherwise require careful shell escaping, or for piping in SQL generated by another tool: + +.. code-block:: bash + + echo "select * from dogs" | sqlite-utils query dogs.db - + +.. code-block:: bash + + sqlite-utils query dogs.db - < query.sql + .. _cli_query_json: Returning JSON diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index fe7dbe4..c6496d4 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1893,7 +1893,15 @@ def query( sqlite-utils data.db \\ "select * from chickens where age > :age" \\ -p age 1 + + Pass "-" as the SQL to read the query from standard input: + + \b + echo "select * from chickens" | sqlite-utils data.db - """ + if sql == "-": + # Read SQL from standard input + sql = sys.stdin.read() db = sqlite_utils.Database(path) _register_db_for_cleanup(db) for alias, attach_path in attach: diff --git a/tests/test_cli.py b/tests/test_cli.py index 06e14ea..8196a08 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -782,6 +782,25 @@ def test_query_json(db_path, sql, args, expected): assert expected == result.output.strip() +def test_query_sql_from_stdin(db_path): + # https://github.com/simonw/sqlite-utils/issues/765 + db = Database(db_path) + with db.conn: + db["dogs"].insert_all( + [ + {"id": 1, "age": 4, "name": "Cleo"}, + {"id": 2, "age": 2, "name": "Pancakes"}, + ] + ) + result = CliRunner().invoke( + cli.cli, + ["query", db_path, "-"], + input="select name from dogs order by name", + ) + assert result.exit_code == 0, result.output + assert json.loads(result.output) == [{"name": "Cleo"}, {"name": "Pancakes"}] + + def test_query_json_empty(db_path): result = CliRunner().invoke( cli.cli, From fa5d66bf5377b00e976ebb3bf92d806ed8f54270 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 7 Jul 2026 17:56:57 -0700 Subject: [PATCH 05/26] Update create-table --help to mention real --- docs/cli-reference.rst | 4 ++-- sqlite_utils/cli.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 000f88f..fd1199f 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -930,10 +930,10 @@ See :ref:`cli_create_table`. sqlite-utils create-table my.db people \ id integer \ name text \ - height float \ + height real \ photo blob --pk id - Valid column types are text, integer, float and blob. + Valid column types are text, integer, real, float and blob. Options: --pk TEXT Column to use as primary key diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index c6496d4..905963f 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1634,10 +1634,10 @@ def create_table( sqlite-utils create-table my.db people \\ id integer \\ name text \\ - height float \\ + height real \\ photo blob --pk id - Valid column types are text, integer, float and blob. + Valid column types are text, integer, real, float and blob. """ db = sqlite_utils.Database(path) _register_db_for_cleanup(db) From 8ee0b7c65cfa34f550672872bffa06183bea9963 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 7 Jul 2026 17:57:29 -0700 Subject: [PATCH 06/26] Fix for rogue quote in enable-fts --help --- docs/cli-reference.rst | 2 +- sqlite_utils/cli.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index fd1199f..e57f4b3 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -1028,7 +1028,7 @@ See :ref:`cli_fts`. Usage: sqlite-utils enable-fts [OPTIONS] PATH TABLE COLUMN... - Enable full-text search for specific table and columns" + Enable full-text search for specific table and columns Example: diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 905963f..99a8c64 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -706,7 +706,7 @@ def create_index( def enable_fts( path, table, column, fts4, fts5, tokenize, create_triggers, replace, load_extension ): - """Enable full-text search for specific table and columns" + """Enable full-text search for specific table and columns Example: From cf3373e7b7fb9342d705d7b5b8d52fe09ac46a34 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 7 Jul 2026 17:59:58 -0700 Subject: [PATCH 07/26] Refactor --functions option, improve help --- docs/cli-reference.rst | 11 ++++++----- sqlite_utils/cli.py | 29 ++++++++++++++--------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index e57f4b3..c232881 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -138,8 +138,8 @@ See :ref:`cli_query`. -r, --raw Raw output, first column of first row --raw-lines Raw output, first column of each row -p, --param ... Named :parameters for SQL query - --functions TEXT Python code or file path defining custom SQL - functions + --functions TEXT Python code or a file path defining custom SQL + functions; can be used multiple times --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -181,8 +181,8 @@ See :ref:`cli_memory`. sqlite-utils memory animals.csv --schema Options: - --functions TEXT Python code or file path defining custom SQL - functions + --functions TEXT Python code or a file path defining custom SQL + functions; can be used multiple times --attach ... Additional databases to attach - specify alias and filepath --flatten Flatten nested JSON objects, so {"foo": {"bar": @@ -383,7 +383,8 @@ See :ref:`cli_bulk`. Options: --batch-size INTEGER Commit every X records - --functions TEXT Python code or file path defining custom SQL functions + --functions TEXT Python code or a file path defining custom SQL + functions; can be used multiple times --flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes {"a_b": 1} --nl Expect newline-delimited JSON diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 99a8c64..edf1634 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -153,6 +153,17 @@ def load_extension_option(fn): )(fn) +def functions_option(fn): + return click.option( + "--functions", + help=( + "Python code or a file path defining custom SQL functions; " + "can be used multiple times" + ), + multiple=True, + )(fn) + + @click.group( cls=DefaultGroup, default="query", @@ -1450,11 +1461,7 @@ def upsert( @click.argument("sql") @click.argument("file", type=click.File("rb"), required=True) @click.option("--batch-size", type=int, default=100, help="Commit every X records") -@click.option( - "--functions", - help="Python code or file path defining custom SQL functions", - multiple=True, -) +@functions_option @import_options @load_extension_option def bulk( @@ -1860,11 +1867,7 @@ def drop_view(path, view, ignore, load_extension): type=(str, str), help="Named :parameters for SQL query", ) -@click.option( - "--functions", - help="Python code or file path defining custom SQL functions", - multiple=True, -) +@functions_option @load_extension_option def query( path, @@ -1937,11 +1940,7 @@ def query( nargs=-1, ) @click.argument("sql") -@click.option( - "--functions", - help="Python code or file path defining custom SQL functions", - multiple=True, -) +@functions_option @click.option( "--attach", type=(str, click.Path(file_okay=True, dir_okay=False, allow_dash=False)), From aa300942bfcba0bd788951db0b9070cf291c193a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 7 Jul 2026 18:03:25 -0700 Subject: [PATCH 08/26] Update sqlite-utils convert --help, refs #686 --- docs/cli-reference.rst | 7 +++++-- sqlite_utils/cli.py | 9 ++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index c232881..105b19a 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -621,6 +621,11 @@ See :ref:`cli_convert`. "value" is a variable with the column value to be converted. + CODE can also be a reference to a callable that takes the value, for example: + + sqlite-utils convert my.db mytable date r.parsedate + sqlite-utils convert my.db mytable data json.loads --import json + Use "-" for CODE to read Python code from standard input. The following common operations are available as recipe functions: @@ -634,7 +639,6 @@ See :ref:`cli_convert`. errors: 'Optional[object]' = None) -> 'Optional[str]' Parse a date and convert it to ISO date format: yyyy-mm-dd - - dayfirst=True: treat xx as the day in xx/yy/zz - yearfirst=True: treat xx as the year in xx/yy/zz - errors=r.IGNORE to ignore values that cannot be parsed @@ -644,7 +648,6 @@ See :ref:`cli_convert`. False, errors: 'Optional[object]' = None) -> 'Optional[str]' Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS - - dayfirst=True: treat xx as the day in xx/yy/zz - yearfirst=True: treat xx as the year in xx/yy/zz - errors=r.IGNORE to ignore values that cannot be parsed diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index edf1634..318b208 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -3054,6 +3054,12 @@ def _generate_convert_help(): "value" is a variable with the column value to be converted. + CODE can also be a reference to a callable that takes the value, for example: + + \b + sqlite-utils convert my.db mytable date r.parsedate + sqlite-utils convert my.db mytable data json.loads --import json + Use "-" for CODE to read Python code from standard input. The following common operations are available as recipe functions: @@ -3067,8 +3073,9 @@ def _generate_convert_help(): ] for name in recipe_names: fn = getattr(recipes, name) + doc = textwrap.dedent(fn.__doc__.rstrip()).replace("\b\n", "") help += "\n\nr.{}{}\n\n\b{}".format( - name, str(inspect.signature(fn)), textwrap.dedent(fn.__doc__.rstrip()) + name, str(inspect.signature(fn)), doc ) help += "\n\n" help += textwrap.dedent(""" From ebafb84c93bd3666e0e6acd24bfafe070f25cdee Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 7 Jul 2026 18:09:12 -0700 Subject: [PATCH 09/26] Allow sqlite-utils upsert to infer --pk from existing table --- docs/cli-reference.rst | 3 ++- docs/cli.rst | 2 ++ sqlite_utils/cli.py | 7 +++++-- tests/test_cli.py | 24 +++++++++++++++++++++--- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 105b19a..5ea9200 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -321,6 +321,8 @@ See :ref:`cli_upsert`. incoming record has a primary key that matches an existing record the existing record will be updated. + If the table already exists and has a primary key, --pk can be omitted. + Example: echo '[ @@ -330,7 +332,6 @@ See :ref:`cli_upsert`. Options: --pk TEXT Columns to use as the primary key, e.g. id - [required] --flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes {"a_b": 1} --nl Expect newline-delimited JSON diff --git a/docs/cli.rst b/docs/cli.rst index bbbbf03..e373704 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1593,6 +1593,8 @@ For example: This will update the dog with an ID of 2 to have an age of 4, creating a new record (with a null name) if one does not exist. If a row DOES exist the name will be left as-is. +If the table already exists and has a primary key, you can omit the ``--pk`` option and ``sqlite-utils`` will use that existing primary key. + The command will fail if you reference columns that do not exist on the table. To automatically create missing columns, use the ``--alter`` option. .. note:: diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 318b208..5e21f92 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -16,6 +16,7 @@ from sqlite_utils.db import ( InvalidColumns, NoTable, NoView, + PrimaryKeyRequired, quote_identifier, ) from sqlite_utils.plugins import ensure_plugins_loaded, pm, get_plugins @@ -1184,7 +1185,7 @@ def insert_upsert_implementation( db.table(table).insert_all( docs, pk=pk, batch_size=batch_size, alter=alter, **extra_kwargs ) - except (NoTable, InvalidColumns) as e: + except (NoTable, InvalidColumns, PrimaryKeyRequired) as e: raise click.ClickException(str(e)) except Exception as e: if ( @@ -1372,7 +1373,7 @@ def insert( @cli.command() -@insert_upsert_options(require_pk=True) +@insert_upsert_options() def upsert( path, table, @@ -1408,6 +1409,8 @@ def upsert( an incoming record has a primary key that matches an existing record the existing record will be updated. + If the table already exists and has a primary key, --pk can be omitted. + Example: \b diff --git a/tests/test_cli.py b/tests/test_cli.py index 8196a08..bc5d492 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1235,20 +1235,38 @@ def test_upsert(db_path, tmpdir): ] -def test_upsert_pk_required(db_path, tmpdir): +def test_upsert_pk_inferred_from_existing_table(db_path, tmpdir): json_path = str(tmpdir / "dogs.json") + db = Database(db_path) insert_dogs = [ {"id": 1, "name": "Cleo", "age": 4}, {"id": 2, "name": "Nixie", "age": 4}, ] write_json(json_path, insert_dogs) + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "dogs", json_path, "--pk", "id"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + + write_json( + json_path, + [ + {"id": 1, "age": 5}, + {"id": 2, "age": 5}, + ], + ) result = CliRunner().invoke( cli.cli, ["upsert", db_path, "dogs", json_path], catch_exceptions=False, ) - assert result.exit_code == 2 - assert "Error: Missing option '--pk'" in result.output + assert result.exit_code == 0, result.output + assert list(db.query("select * from dogs order by id")) == [ + {"id": 1, "name": "Cleo", "age": 5}, + {"id": 2, "name": "Nixie", "age": 5}, + ] def test_upsert_analyze(db_path, tmpdir): From 23a21c1d6bc8aa1c17a49b37181aadffd7abb8be Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 7 Jul 2026 18:11:34 -0700 Subject: [PATCH 10/26] Ran Black --- sqlite_utils/cli.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 5e21f92..b99dcf1 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -3077,9 +3077,7 @@ def _generate_convert_help(): for name in recipe_names: fn = getattr(recipes, name) doc = textwrap.dedent(fn.__doc__.rstrip()).replace("\b\n", "") - help += "\n\nr.{}{}\n\n\b{}".format( - name, str(inspect.signature(fn)), doc - ) + help += "\n\nr.{}{}\n\n\b{}".format(name, str(inspect.signature(fn)), doc) help += "\n\n" help += textwrap.dedent(""" You can use these recipes like so: From 569608e40f28893262d727adadddbf3d077112da Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 7 Jul 2026 18:48:57 -0700 Subject: [PATCH 11/26] sqlite-utils insert --code option, closes #684 --- docs/changelog.rst | 1 + docs/cli-reference.rst | 18 ++- docs/cli.rst | 21 ++++ sqlite_utils/cli.py | 232 +++++++++++++++++++++++++++------------ tests/test_cli_insert.py | 150 +++++++++++++++++++++++++ 5 files changed, 347 insertions(+), 75 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index cf9486a..a9dd4a3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,7 @@ Unreleased ---------- - ``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`) +- ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for providing a block of Python 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`) .. _v4_0: diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 5ea9200..5798809 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -230,7 +230,7 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr :: - Usage: sqlite-utils insert [OPTIONS] PATH TABLE FILE + Usage: sqlite-utils insert [OPTIONS] PATH TABLE [FILE] Insert records from FILE into a table, creating the table if it does not already exist. @@ -272,8 +272,20 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr echo 'A bunch of words' | sqlite-utils insert words.db words - \ --text --convert '({"word": w} for w in text.split())' + Instead of a FILE you can use --code to provide a block of Python code that + defines the rows to insert, as either a rows() function that yields + dictionaries or a "rows" iterable. --code can also be a path to a .py file: + + sqlite-utils insert data.db creatures --code ' + def rows(): + yield {"id": 1, "name": "Cleo"} + yield {"id": 2, "name": "Suna"} + ' --pk id + Options: --pk TEXT Columns to use as the primary key, e.g. id + --code TEXT Python code defining a rows() function or iterable + of rows to insert --flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes {"a_b": 1} --nl Expect newline-delimited JSON @@ -315,7 +327,7 @@ See :ref:`cli_upsert`. :: - Usage: sqlite-utils upsert [OPTIONS] PATH TABLE FILE + Usage: sqlite-utils upsert [OPTIONS] PATH TABLE [FILE] Upsert records based on their primary key. Works like 'insert' but if an incoming record has a primary key that matches an existing record the existing @@ -332,6 +344,8 @@ See :ref:`cli_upsert`. Options: --pk TEXT Columns to use as the primary key, e.g. id + --code TEXT Python code defining a rows() function or iterable + of rows to insert --flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes {"a_b": 1} --nl Expect newline-delimited JSON diff --git a/docs/cli.rst b/docs/cli.rst index e373704..4ed92ed 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1561,6 +1561,27 @@ The result looks like this: COMMIT; +.. _cli_insert_code: + +Inserting rows generated by Python code +======================================= + +Instead of providing a ``FILE`` to import, you can use the ``--code`` option to pass a block of Python code that generates the rows to insert. This is the command-line equivalent of calling ``db["creatures"].insert_all(rows())`` from the :ref:`Python API `. + +Your code should define either a ``rows()`` function that returns or yields dictionaries, or a ``rows`` iterable such as a list of dictionaries: + +.. code-block:: bash + + sqlite-utils insert data.db creatures --code ' + def rows(): + yield {"id": 1, "name": "Cleo"} + yield {"id": 2, "name": "Suna"} + ' --pk id + +``--code`` can also be given a path to a Python ``.py`` file. + +The ``--code`` option works with both ``sqlite-utils insert`` and ``sqlite-utils upsert``, and composes with table options such as ``--pk``, ``--replace``, ``--alter``, ``--not-null`` and ``--default``. It cannot be combined with a ``FILE`` argument or with input format options such as ``--csv`` or ``--convert``. + .. _cli_insert_replace: Insert-replacing data diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index b99dcf1..4eefc70 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -944,13 +944,19 @@ def insert_upsert_options(*, require_pk=False): required=True, ), click.argument("table"), - click.argument("file", type=click.File("rb", lazy=True), required=True), + click.argument( + "file", type=click.File("rb", lazy=True), required=False + ), click.option( "--pk", help="Columns to use as the primary key, e.g. id", multiple=True, required=require_pk, ), + click.option( + "--code", + help="Python code defining a rows() function or iterable of rows to insert", + ), ) + _import_options + ( @@ -1035,11 +1041,118 @@ def insert_upsert_implementation( bulk_sql=None, functions=None, strict=False, + code=None, ): db = sqlite_utils.Database(path) _register_db_for_cleanup(db) _load_extensions(db, load_extension) _maybe_register_functions(db, functions) + + def _insert_docs(docs, tracker=None): + extra_kwargs = { + "ignore": ignore, + "replace": replace, + "truncate": truncate, + "analyze": analyze, + "strict": strict, + } + if not_null: + extra_kwargs["not_null"] = set(not_null) + if default: + extra_kwargs["defaults"] = dict(default) + if upsert: + extra_kwargs["upsert"] = upsert + + # docs should all be dictionaries + docs = (verify_is_dict(doc) for doc in docs) + + # Apply {"$base64": true, ...} decoding, if needed + docs = (decode_base64_values(doc) for doc in docs) + + # For bulk_sql= we use cursor.executemany() instead + if bulk_sql: + if batch_size: + doc_chunks = chunks(docs, batch_size) + else: + doc_chunks = [docs] + for doc_chunk in doc_chunks: + with db.atomic(): + db.conn.cursor().executemany(bulk_sql, doc_chunk) + return + + # table_names() rather than db.table(), which raises NoTable for + # views before the error handling below can deal with them + table_existed_before_insert = table in db.table_names() + try: + db.table(table).insert_all( + docs, pk=pk, batch_size=batch_size, alter=alter, **extra_kwargs + ) + except (NoTable, InvalidColumns, PrimaryKeyRequired) as e: + raise click.ClickException(str(e)) + except Exception as e: + if ( + isinstance(e, OperationalError) + and e.args + and ( + "has no column named" in e.args[0] or "no such column" in e.args[0] + ) + ): + raise click.ClickException( + "{}\n\nTry using --alter to add additional columns".format( + e.args[0] + ) + ) + # If we can find sql= and parameters= arguments, show those + variables = _find_variables(e.__traceback__, ["sql", "parameters"]) + if "sql" in variables and "parameters" in variables: + raise click.ClickException( + "{}\n\nsql = {}\nparameters = {}".format( + str(e), variables["sql"], variables["parameters"] + ) + ) + else: + raise + # Apply detected types only to a table this command created - + # transforming a pre-existing table would rewrite its column types + # and corrupt values such as TEXT zip codes with leading zeros + if ( + tracker is not None + and not table_existed_before_insert + and db.table(table).exists() + ): + db.table(table).transform(types=tracker.types) + + if code is not None: + if file is not None: + raise click.ClickException("--code cannot be used with a FILE argument") + if any( + [ + flatten, + nl, + csv, + tsv, + empty_null, + lines, + text, + convert, + sniff, + no_headers, + delimiter, + quotechar, + encoding, + ] + ): + raise click.ClickException( + "--code cannot be used with input format options" + ) + _insert_docs(_rows_from_code(code)) + return + + if file is None: + raise click.ClickException( + "Provide either a FILE argument or --code to specify rows to insert" + ) + if (delimiter or quotechar or sniff or no_headers) and not tsv: csv = True if (nl + csv + tsv) >= 2: @@ -1147,78 +1260,7 @@ def insert_upsert_implementation( else: docs = (fn(doc) or doc for doc in docs) - extra_kwargs = { - "ignore": ignore, - "replace": replace, - "truncate": truncate, - "analyze": analyze, - "strict": strict, - } - if not_null: - extra_kwargs["not_null"] = set(not_null) - if default: - extra_kwargs["defaults"] = dict(default) - if upsert: - extra_kwargs["upsert"] = upsert - - # docs should all be dictionaries - docs = (verify_is_dict(doc) for doc in docs) - - # Apply {"$base64": true, ...} decoding, if needed - docs = (decode_base64_values(doc) for doc in docs) - - # For bulk_sql= we use cursor.executemany() instead - if bulk_sql: - if batch_size: - doc_chunks = chunks(docs, batch_size) - else: - doc_chunks = [docs] - for doc_chunk in doc_chunks: - with db.atomic(): - db.conn.cursor().executemany(bulk_sql, doc_chunk) - return - - # table_names() rather than db.table(), which raises NoTable for - # views before the error handling below can deal with them - table_existed_before_insert = table in db.table_names() - try: - db.table(table).insert_all( - docs, pk=pk, batch_size=batch_size, alter=alter, **extra_kwargs - ) - except (NoTable, InvalidColumns, PrimaryKeyRequired) as e: - raise click.ClickException(str(e)) - except Exception as e: - if ( - isinstance(e, OperationalError) - and e.args - and ( - "has no column named" in e.args[0] or "no such column" in e.args[0] - ) - ): - raise click.ClickException( - "{}\n\nTry using --alter to add additional columns".format( - e.args[0] - ) - ) - # If we can find sql= and parameters= arguments, show those - variables = _find_variables(e.__traceback__, ["sql", "parameters"]) - if "sql" in variables and "parameters" in variables: - raise click.ClickException( - "{}\n\nsql = {}\nparameters = {}".format( - str(e), variables["sql"], variables["parameters"] - ) - ) - else: - raise - # Apply detected types only to a table this command created - - # transforming a pre-existing table would rewrite its column types - # and corrupt values such as TEXT zip codes with leading zeros - if ( - tracker is not None - and not table_existed_before_insert - and db.table(table).exists() - ): - db.table(table).transform(types=tracker.types) + _insert_docs(docs, tracker=tracker) # Clean up open file-like objects if sniff_buffer: @@ -1261,6 +1303,7 @@ def insert( table, file, pk, + code, flatten, nl, csv, @@ -1332,6 +1375,17 @@ def insert( \b echo 'A bunch of words' | sqlite-utils insert words.db words - \\ --text --convert '({"word": w} for w in text.split())' + + Instead of a FILE you can use --code to provide a block of Python code + that defines the rows to insert, as either a rows() function that yields + dictionaries or a "rows" iterable. --code can also be a path to a .py file: + + \b + sqlite-utils insert data.db creatures --code ' + def rows(): + yield {"id": 1, "name": "Cleo"} + yield {"id": 2, "name": "Suna"} + ' --pk id """ try: insert_upsert_implementation( @@ -1367,6 +1421,7 @@ def insert( not_null=not_null, default=default, strict=strict, + code=code, ) except UnicodeDecodeError as ex: raise click.ClickException(UNICODE_ERROR.format(ex)) @@ -1379,6 +1434,7 @@ def upsert( table, file, pk, + code, flatten, nl, csv, @@ -1450,6 +1506,7 @@ def upsert( load_extension=load_extension, silent=silent, strict=strict, + code=code, ) except UnicodeDecodeError as ex: raise click.ClickException(UNICODE_ERROR.format(ex)) @@ -3669,3 +3726,32 @@ def _maybe_register_functions(db, functions_list): for functions in functions_list: if isinstance(functions, str) and functions.strip(): _register_functions(db, functions) + + +def _rows_from_code(code): + # code may be a path to a .py file + if "\n" not in code and code.endswith(".py"): + try: + code = pathlib.Path(code).read_text() + except FileNotFoundError: + raise click.ClickException("File not found: {}".format(code)) + namespace = {} + try: + exec(code, namespace) + except SyntaxError as ex: + raise click.ClickException("Error in --code: {}".format(ex)) + rows = namespace.get("rows") + if callable(rows): + rows = rows() + if isinstance(rows, dict): + rows = [rows] + error = click.ClickException( + "--code must define a 'rows' function or iterable of rows to insert" + ) + if rows is None or isinstance(rows, (str, bytes)): + raise error + try: + iter(rows) + except TypeError: + raise error + return rows diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index 5af8a2f..e290f97 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -691,3 +691,153 @@ def test_insert_invalid_pk_clean_error(db_path): assert result.exit_code == 1 assert result.exception is None or isinstance(result.exception, SystemExit) assert result.output.startswith("Error: Invalid primary key column") + + +# --code tests, see https://github.com/simonw/sqlite-utils/issues/684 +CODE_ROWS_FUNCTION = """ +def rows(): + yield {"id": 1, "name": "Cleo"} + yield {"id": 2, "name": "Suna"} +""" + +CODE_ROWS_ITERABLE = """ +rows = [ + {"id": 1, "name": "Cleo"}, + {"id": 2, "name": "Suna"}, +] +""" + + +@pytest.mark.parametrize("code", (CODE_ROWS_FUNCTION, CODE_ROWS_ITERABLE)) +def test_insert_code(tmpdir, code): + db_path = str(tmpdir / "dogs.db") + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "creatures", "--code", code, "--pk", "id"], + ) + assert result.exit_code == 0, result.output + db = Database(db_path) + assert db["creatures"].pks == ["id"] + assert list(db["creatures"].rows) == [ + {"id": 1, "name": "Cleo"}, + {"id": 2, "name": "Suna"}, + ] + + +def test_insert_code_from_file(tmpdir): + db_path = str(tmpdir / "dogs.db") + code_path = str(tmpdir / "gen.py") + with open(code_path, "w") as fp: + fp.write(CODE_ROWS_FUNCTION) + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "creatures", "--code", code_path], + ) + assert result.exit_code == 0, result.output + assert list(Database(db_path)["creatures"].rows) == [ + {"id": 1, "name": "Cleo"}, + {"id": 2, "name": "Suna"}, + ] + + +def test_upsert_code(tmpdir): + db_path = str(tmpdir / "dogs.db") + db = Database(db_path) + db["creatures"].insert_all( + [{"id": 1, "name": "old"}, {"id": 2, "name": "Suna"}], pk="id" + ) + result = CliRunner().invoke( + cli.cli, + ["upsert", db_path, "creatures", "--code", CODE_ROWS_FUNCTION, "--pk", "id"], + ) + assert result.exit_code == 0, result.output + assert list(db["creatures"].rows) == [ + {"id": 1, "name": "Cleo"}, + {"id": 2, "name": "Suna"}, + ] + + +def test_insert_code_requires_file_or_code(tmpdir): + db_path = str(tmpdir / "dogs.db") + result = CliRunner().invoke(cli.cli, ["insert", db_path, "creatures"]) + assert result.exit_code == 1 + assert "Provide either a FILE argument or --code" in result.output + + +def test_insert_code_mutually_exclusive_with_file(tmpdir): + db_path = str(tmpdir / "dogs.db") + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "creatures", "-", "--code", CODE_ROWS_FUNCTION], + input="{}", + ) + assert result.exit_code == 1 + assert "--code cannot be used with a FILE argument" in result.output + + +def test_insert_code_rejects_input_format_options(tmpdir): + db_path = str(tmpdir / "dogs.db") + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "creatures", "--code", CODE_ROWS_FUNCTION, "--csv"], + ) + assert result.exit_code == 1 + assert "--code cannot be used with input format options" in result.output + + +def test_insert_code_missing_rows(tmpdir): + db_path = str(tmpdir / "dogs.db") + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "creatures", "--code", "x = 1"], + ) + assert result.exit_code == 1 + assert "must define a 'rows' function or iterable" in result.output + + +def test_insert_code_single_dict(tmpdir): + db_path = str(tmpdir / "dogs.db") + result = CliRunner().invoke( + cli.cli, + [ + "insert", + db_path, + "creatures", + "--code", + 'rows = {"id": 1, "name": "Cleo"}', + "--pk", + "id", + ], + ) + assert result.exit_code == 0, result.output + assert list(Database(db_path)["creatures"].rows) == [{"id": 1, "name": "Cleo"}] + + +def test_insert_code_not_iterable(tmpdir): + db_path = str(tmpdir / "dogs.db") + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "creatures", "--code", "rows = 5"], + ) + assert result.exit_code == 1 + assert "must define a 'rows' function or iterable" in result.output + + +def test_insert_code_syntax_error(tmpdir): + db_path = str(tmpdir / "dogs.db") + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "creatures", "--code", "def rows(:"], + ) + assert result.exit_code == 1 + assert "Error in --code" in result.output + + +def test_insert_code_file_not_found(tmpdir): + db_path = str(tmpdir / "dogs.db") + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "creatures", "--code", "missing.py"], + ) + assert result.exit_code == 1 + assert "File not found: missing.py" in result.output From 092f0919c3c254de0801dda60e7bbf8b73818c6c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 7 Jul 2026 18:57:27 -0700 Subject: [PATCH 12/26] Changelog tweak refs #684 --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index a9dd4a3..5d1816a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,7 +10,7 @@ Unreleased ---------- - ``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`) -- ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for providing a block of Python 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`) +- ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for :ref:`providing a block of Python 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`) .. _v4_0: From d2ac3765ed9f0516bb0cbc2508a5c3907fb6a71a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 7 Jul 2026 19:17:29 -0700 Subject: [PATCH 13/26] sqlite-utils insert/upsert --type colunm-name type option, closes #131 --- docs/changelog.rst | 1 + docs/cli-reference.rst | 8 +++++++ docs/cli.rst | 19 ++++++++++++++++ sqlite_utils/cli.py | 28 +++++++++++++++++++++++- tests/test_cli_insert.py | 47 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5d1816a..9e03f39 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,7 @@ Unreleased - ``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`) - ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for :ref:`providing a block of Python 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`) +- ``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 `. 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`) .. _v4_0: diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 5798809..39226ac 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -246,6 +246,9 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr - Use --lines to write each incoming line to a column called "line" - Use --text to write the entire input to a column called "text" + Use --type column-name type to override the type automatically chosen when the + table is created. + You can also use --convert to pass a fragment of Python code that will be used to convert each input. @@ -306,6 +309,7 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr --alter Alter existing table to add any missing columns --not-null TEXT Columns that should be created as NOT NULL --default ... Default value that should be set for a column + --type ... Column types to use when creating the table --no-detect-types Treat all CSV/TSV columns as TEXT --analyze Run ANALYZE at the end of this operation --load-extension TEXT Path to SQLite extension, with optional :entrypoint @@ -335,6 +339,9 @@ See :ref:`cli_upsert`. If the table already exists and has a primary key, --pk can be omitted. + Use --type column-name type to override the type automatically chosen when the + table is created. + Example: echo '[ @@ -366,6 +373,7 @@ See :ref:`cli_upsert`. --alter Alter existing table to add any missing columns --not-null TEXT Columns that should be created as NOT NULL --default ... Default value that should be set for a column + --type ... Column types to use when creating the table --no-detect-types Treat all CSV/TSV columns as TEXT --analyze Run ANALYZE at the end of this operation --load-extension TEXT Path to SQLite extension, with optional :entrypoint diff --git a/docs/cli.rst b/docs/cli.rst index 4ed92ed..731e93b 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1366,6 +1366,25 @@ Will produce this schema with automatically detected types: "weight" REAL ); +.. _cli_insert_csv_tsv_column_types: + +Overriding column types +----------------------- + +Use ``--type column-name type`` to override the type automatically chosen when the table is created. This option can be used more than once, and works with both ``insert`` and ``upsert``: + +.. code-block:: bash + + sqlite-utils insert places.db places places.csv --csv \ + --type zipcode text \ + --type score real + +This is useful for values such as ZIP codes, which may look like integers but should be stored as ``TEXT`` to preserve leading zeros. + +The column type should be one of ``TEXT``, ``INTEGER``, ``FLOAT``, ``REAL`` or ``BLOB``. Column types are matched case-insensitively. + +As with detected column types, ``--type`` only affects tables created by the command. If the table already exists, its existing column types are left unchanged. + To disable type detection and treat all columns as TEXT, use ``--no-detect-types``: .. code-block:: bash diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 4eefc70..cf39ff8 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -980,6 +980,16 @@ def insert_upsert_options(*, require_pk=False): type=(str, str), help="Default value that should be set for a column", ), + click.option( + "--type", + "types", + type=( + str, + click.Choice(list(VALID_COLUMN_TYPES), case_sensitive=False), + ), + multiple=True, + help="Column types to use when creating the table", + ), click.option( "--no-detect-types", is_flag=True, @@ -1034,6 +1044,7 @@ def insert_upsert_implementation( truncate=False, not_null=None, default=None, + types=None, no_detect_types=False, analyze=False, load_extension=None, @@ -1047,6 +1058,7 @@ def insert_upsert_implementation( _register_db_for_cleanup(db) _load_extensions(db, load_extension) _maybe_register_functions(db, functions) + column_type_overrides = {column: ctype.upper() for column, ctype in (types or [])} def _insert_docs(docs, tracker=None): extra_kwargs = { @@ -1060,6 +1072,8 @@ def insert_upsert_implementation( extra_kwargs["not_null"] = set(not_null) if default: extra_kwargs["defaults"] = dict(default) + if column_type_overrides: + extra_kwargs["columns"] = column_type_overrides if upsert: extra_kwargs["upsert"] = upsert @@ -1120,7 +1134,9 @@ def insert_upsert_implementation( and not table_existed_before_insert and db.table(table).exists() ): - db.table(table).transform(types=tracker.types) + detected_types = tracker.types + detected_types.update(column_type_overrides) + db.table(table).transform(types=detected_types) if code is not None: if file is not None: @@ -1330,6 +1346,7 @@ def insert( truncate, not_null, default, + types, strict, ): """ @@ -1348,6 +1365,9 @@ def insert( - Use --lines to write each incoming line to a column called "line" - Use --text to write the entire input to a column called "text" + Use --type column-name type to override the type automatically chosen + when the table is created. + You can also use --convert to pass a fragment of Python code that will be used to convert each input. @@ -1420,6 +1440,7 @@ def insert( silent=silent, not_null=not_null, default=default, + types=types, strict=strict, code=code, ) @@ -1454,6 +1475,7 @@ def upsert( alter, not_null, default, + types, no_detect_types, analyze, load_extension, @@ -1467,6 +1489,9 @@ def upsert( If the table already exists and has a primary key, --pk can be omitted. + Use --type column-name type to override the type automatically chosen + when the table is created. + Example: \b @@ -1501,6 +1526,7 @@ def upsert( upsert=True, not_null=not_null, default=default, + types=types, no_detect_types=no_detect_types, analyze=analyze, load_extension=load_extension, diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index e290f97..df6f80c 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -664,6 +664,53 @@ def test_insert_csv_detect_types_new_table(db_path): assert db["data"].columns_dict == {"name": str, "age": int, "weight": float} +@pytest.mark.parametrize( + "command,extra_args,input_text,expected_row", + ( + ( + "insert", + [], + "zipcode,score\n01234,9.5\n", + {"zipcode": "01234", "score": 9.5}, + ), + ( + "upsert", + ["--pk", "id"], + "id,zipcode,score\n1,01234,9.5\n", + {"id": 1, "zipcode": "01234", "score": 9.5}, + ), + ), +) +def test_insert_upsert_csv_type_overrides_detected_types( + db_path, command, extra_args, input_text, expected_row +): + result = CliRunner().invoke( + cli.cli, + [ + command, + db_path, + "places", + "-", + "--csv", + ] + + extra_args + + [ + "--type", + "zipcode", + "text", + ], + catch_exceptions=False, + input=input_text, + ) + assert result.exit_code == 0, result.output + db = Database(db_path) + expected_columns = {"zipcode": str, "score": float} + if command == "upsert": + expected_columns = {"id": int, **expected_columns} + assert db["places"].columns_dict == expected_columns + assert list(db["places"].rows) == [expected_row] + + def test_upsert_csv_detect_types_leaves_existing_table_alone(db_path): db = Database(db_path) db["places"].insert({"id": 1, "name": "Boston", "zip": "01234"}, pk="id") From d302835d57bcf53c36c0dc67356ec292f55a5931 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Wed, 13 May 2026 11:55:13 -0700 Subject: [PATCH 14/26] feat(db): document and test Database.memory and Database.memory_name Refs #590, closes #734 --- docs/python-api.rst | 8 ++++++++ tests/test_constructor.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/docs/python-api.rst b/docs/python-api.rst index 11af1f4..f8e0787 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -109,6 +109,14 @@ You can also create a named in-memory database. Unlike regular memory databases db = Database(memory_name="my_shared_database") +After creating a ``Database`` you can use ``db.memory`` and ``db.memory_name`` to tell whether it is backed by an in-memory database and to read the shared cache name. ``db.memory`` is ``True`` for any in-memory database and ``db.memory_name`` holds the name passed to ``memory_name=``, or ``None`` otherwise. + +.. code-block:: python + + db = Database(memory_name="shared") + db.memory # True + db.memory_name # "shared" + Connections use ``PRAGMA recursive_triggers=on`` by default. If you don't want to use `recursive triggers `__ you can turn them off using: .. code-block:: python diff --git a/tests/test_constructor.py b/tests/test_constructor.py index 7714f26..7428dcf 100644 --- a/tests/test_constructor.py +++ b/tests/test_constructor.py @@ -87,3 +87,34 @@ def test_legacy_transaction_control_connection_is_accepted(tmpdir): db["t"].insert({"id": 1}, pk="id") assert [r["id"] for r in db["t"].rows] == [1] db.close() + + +def test_memory_attribute_for_memory_true(): + db = Database(memory=True) + assert db.memory is True + assert db.memory_name is None + + +def test_memory_attribute_for_memory_name(): + db = Database(memory_name="shared_attr") + assert db.memory is True + assert db.memory_name == "shared_attr" + + +def test_memory_attribute_for_memory_string_path(): + db = Database(":memory:") + assert db.memory is True + assert db.memory_name is None + + +def test_memory_attribute_for_file_path(tmpdir): + db = Database(str(tmpdir / "file.db")) + assert db.memory is False + assert db.memory_name is None + + +def test_memory_attribute_for_existing_connection(): + conn = sqlite3.connect(":memory:") + db = Database(conn) + assert db.memory is False + assert db.memory_name is None From 7a52214624ae0e2c3fdf07215c1bcfc1393dbd93 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 7 Jul 2026 21:00:43 -0700 Subject: [PATCH 15/26] drop-index command and table.drop_index(index_name) Closes #626 --- docs/changelog.rst | 1 + docs/cli-reference.rst | 26 +++++++++++++++++++++++++- docs/cli.rst | 13 +++++++++++++ docs/python-api.rst | 8 ++++++++ sqlite_utils/cli.py | 28 ++++++++++++++++++++++++++++ sqlite_utils/db.py | 16 ++++++++++++++++ tests/test_cli.py | 18 ++++++++++++++++++ tests/test_create.py | 28 ++++++++++++++++++++++++++++ 8 files changed, 137 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9e03f39..d2c07da 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,6 +12,7 @@ Unreleased - ``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`) - ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for :ref:`providing a block of Python 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`) - ``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 `. 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`) +- New ``table.drop_index(name)`` method and ``sqlite-utils drop-index`` command for dropping an index by name. Both accept ``ignore=True``/``--ignore`` to ignore a missing index. (:issue:`626`) .. _v4_0: diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 39226ac..71e8377 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -19,7 +19,7 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command. go_first = [ "query", "memory", "insert", "upsert", "bulk", "search", "transform", "extract", "schema", "insert-files", "analyze-tables", "convert", "tables", "views", "rows", - "triggers", "indexes", "create-database", "create-table", "create-index", + "triggers", "indexes", "create-database", "create-table", "create-index", "drop-index", "migrate", "enable-fts", "populate-fts", "rebuild-fts", "disable-fts" ] refs = { @@ -46,6 +46,7 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command. "add-foreign-keys": "cli_add_foreign_keys", "index-foreign-keys": "cli_index_foreign_keys", "create-index": "cli_create_index", + "drop-index": "cli_drop_index", "enable-wal": "cli_wal", "enable-counts": "cli_enable_counts", "bulk": "cli_bulk", @@ -1006,6 +1007,29 @@ See :ref:`cli_create_index`. -h, --help Show this message and exit. +.. _cli_ref_drop_index: + +drop-index +========== + +See :ref:`cli_drop_index`. + +:: + + Usage: sqlite-utils drop-index [OPTIONS] PATH TABLE INDEX + + Drop an index by index name from the specified table + + Example: + + sqlite-utils drop-index chickens.db chickens idx_chickens_name + + Options: + --ignore Ignore if index does not exist + --load-extension TEXT Path to SQLite extension, with optional :entrypoint + -h, --help Show this message and exit. + + .. _cli_ref_migrate: migrate diff --git a/docs/cli.rst b/docs/cli.rst index 731e93b..c446a72 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -2615,6 +2615,19 @@ If your column names are already prefixed with a hyphen you'll need to manually Add the ``--analyze`` option to run ``ANALYZE`` against the index after it has been created. +.. _cli_drop_index: + +Dropping indexes +================ + +You can drop an index from an existing table using the ``drop-index`` command: + +.. code-block:: bash + + sqlite-utils drop-index mydb.db mytable idx_mytable_col1 + +Use ``--ignore`` to ignore the error if the index does not exist on that table. + .. _cli_fts: Configuring full-text search diff --git a/docs/python-api.rst b/docs/python-api.rst index f8e0787..fed617e 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -2877,6 +2877,14 @@ Use ``if_not_exists=True`` to do nothing if an index with that name already exis Pass ``analyze=True`` to run ``ANALYZE`` against the new index after creating it. +You can drop an index from a table using ``.drop_index(index_name)``: + +.. code-block:: python + + db.table("dogs").drop_index("idx_dogs_name") + +Use ``ignore=True`` to ignore the error if the index does not exist. + .. _python_api_analyze: Optimizing index usage with ANALYZE diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index cf39ff8..7fab72b 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -692,6 +692,34 @@ def create_index( ) +@cli.command(name="drop-index") +@click.argument( + "path", + type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + required=True, +) +@click.argument("table") +@click.argument("index") +@click.option("--ignore", help="Ignore if index does not exist", is_flag=True) +@load_extension_option +def drop_index(path, table, index, ignore, load_extension): + """ + Drop an index by index name from the specified table + + Example: + + \b + sqlite-utils drop-index chickens.db chickens idx_chickens_name + """ + db = sqlite_utils.Database(path) + _register_db_for_cleanup(db) + _load_extensions(db, load_extension) + try: + db.table(table).drop_index(index, ignore=ignore) + except OperationalError as ex: + raise click.ClickException(str(ex)) + + @cli.command(name="enable-fts") @click.argument( "path", diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 3033a36..62e3656 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -3080,6 +3080,22 @@ class Table(Queryable): self.db.analyze(created_index_name) return self + def drop_index(self, index_name: str, ignore: bool = False): + """ + Drop an index on this table. + + :param index_name: Name of the index to drop + :param ignore: Set to ``True`` to ignore the error if the index does not exist + """ + if index_name not in {index.name for index in self.indexes}: + if ignore: + return self + raise OperationalError( + "No index named {} on table {}".format(index_name, self.name) + ) + self.db.execute("DROP INDEX {}".format(quote_identifier(index_name))) + return self + def add_column( self, col_name: str, diff --git a/tests/test_cli.py b/tests/test_cli.py index bc5d492..a828c70 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -291,6 +291,24 @@ def test_create_index(db_path): ) +def test_drop_index(db_path): + db = Database(db_path) + db["Gosh"].create_index(["c1"]) + assert [index.name for index in db["Gosh"].indexes] == ["idx_Gosh_c1"] + result = CliRunner().invoke(cli.cli, ["drop-index", db_path, "Gosh", "idx_Gosh_c1"]) + assert result.exit_code == 0 + assert db["Gosh"].indexes == [] + + result = CliRunner().invoke(cli.cli, ["drop-index", db_path, "Gosh", "idx_Gosh_c1"]) + assert result.exit_code == 1 + assert "No index named idx_Gosh_c1" in result.output + + result = CliRunner().invoke( + cli.cli, ["drop-index", db_path, "Gosh", "idx_Gosh_c1", "--ignore"] + ) + assert result.exit_code == 0 + + def test_create_index_analyze(db_path): db = Database(db_path) assert "sqlite_stat1" not in db.table_names() diff --git a/tests/test_create.py b/tests/test_create.py index 7fab5e6..d281eb4 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -809,6 +809,34 @@ def test_create_index_if_not_exists(fresh_db): dogs.create_index(["name"], if_not_exists=True) +def test_drop_index(fresh_db): + dogs = fresh_db["dogs"] + dogs.insert({"name": "Cleo", "twitter": "cleopaws", "age": 3, "is_good_dog": True}) + dogs.create_index(["name"]) + assert [index.name for index in dogs.indexes] == ["idx_dogs_name"] + dogs.drop_index("idx_dogs_name") + assert dogs.indexes == [] + + +def test_drop_index_ignore(fresh_db): + dogs = fresh_db["dogs"] + dogs.insert({"name": "Cleo"}) + with pytest.raises(OperationalError, match="No index named idx_dogs_name"): + dogs.drop_index("idx_dogs_name") + dogs.drop_index("idx_dogs_name", ignore=True) + + +def test_drop_index_wrong_table(fresh_db): + dogs = fresh_db["dogs"] + cats = fresh_db["cats"] + dogs.insert({"name": "Cleo"}) + cats.insert({"name": "Misty"}) + dogs.create_index(["name"]) + with pytest.raises(OperationalError, match="No index named idx_dogs_name"): + cats.drop_index("idx_dogs_name") + assert [index.name for index in dogs.indexes] == ["idx_dogs_name"] + + def test_create_index_desc(fresh_db): dogs = fresh_db["dogs"] dogs.insert({"name": "Cleo", "twitter": "cleopaws", "age": 3, "is good dog": True}) From 0f2d525d0686c7cafcd819ab20bf0f89c6bf9f69 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 11 Jul 2026 16:20:37 -0700 Subject: [PATCH 16/26] Clarify transaction documentation Based on extensive digging into how this stuff all works. --- docs/python-api.rst | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index fed617e..d444806 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -320,11 +320,15 @@ Every method in this library that writes to the database - ``insert()``, ``upser The same applies to raw SQL executed with :ref:`db.execute() ` - a write statement is committed as soon as it has run. +Another way to think about this is that each sqlite-utils method call is its own unit of work. If several method calls must either all succeed or all fail, use ``db.atomic()`` to turn them into a single unit of work. + You never need to call ``commit()``, and you do not need to close the database to persist your changes. There are exactly two situations where you need to think about transactions: 1. You want to group several write operations together, so they either all succeed or all fail - use :ref:`db.atomic() `. 2. You are :ref:`managing a transaction yourself ` with ``db.begin()``, in which case nothing is committed until you commit - the library will never commit a transaction you opened. +``with Database(...) as db:`` is not a transaction block. It manages the lifetime of the database connection and closes it on exit. Use ``with db.atomic():`` for a transaction. + .. _python_api_atomic: Grouping changes with db.atomic() @@ -340,6 +344,27 @@ Use ``db.atomic()`` to group multiple operations in a single transaction: The transaction commits when the block exits. If an exception is raised, changes made inside the block will be rolled back. +This matters when several operations represent a single logical change. Without ``db.atomic()``, an earlier method call remains committed if a later one fails: + +.. code-block:: python + + # These are two separate transactions + db.table("accounts").update(1, {"balance": 90}) + db.table("accounts").update(2, {"balance": 110}) + + # These updates either both succeed or both fail + with db.atomic(): + db.table("accounts").update(1, {"balance": 90}) + db.table("accounts").update(2, {"balance": 110}) + +Transactions can also improve performance. Calling ``insert()`` repeatedly outside ``db.atomic()`` creates and commits a separate transaction for every call. For bulk inserts, prefer :ref:`insert_all() `. If you need to call several different methods in a loop, wrap the loop in ``db.atomic()``: + +.. code-block:: python + + with db.atomic(): + for row in rows: + db.table("events").insert(row) + ``db.atomic()`` can be nested. Nested blocks use SQLite savepoints, so an exception in an inner block can roll back to that savepoint without rolling back the entire outer transaction: .. code-block:: python @@ -368,6 +393,8 @@ Write statements executed with :ref:`db.execute() ` follow t db.execute("insert into news (headline) values (?)", ["Dog wins award"]) # Already committed +``db.execute()`` participates in sqlite-utils transaction handling. Calling ``db.conn.execute()`` directly bypasses that policy and leaves transaction handling to Python's underlying ``sqlite3.Connection``. Prefer ``db.execute()`` unless you deliberately need the lower-level API. + If a transaction is open - because the call happens inside a ``db.atomic()`` block, or after ``db.begin()`` - the statement becomes part of that transaction instead, and commits when the transaction commits: .. code-block:: python @@ -399,6 +426,8 @@ You can take full manual control using the ``db.begin()``, ``db.commit()`` and ` The library will never commit a transaction you opened. If you call write methods such as ``insert()`` - or use ``db.atomic()`` - while your transaction is open, they participate in it using SQLite savepoints instead of committing: exiting an ``atomic()`` block releases its savepoint, but nothing is saved to disk until you commit the outer transaction yourself. If you roll back, their changes are rolled back too. +Prefer ``db.atomic()`` or ``db.begin()``, ``db.commit()`` and ``db.rollback()`` over mixing sqlite-utils transaction methods with calls to ``db.conn.commit()``, ``db.conn.rollback()`` or raw transaction-control SQL. Mixing the two layers makes it much harder to tell which layer owns the current transaction. + Two related safeguards to be aware of: - ``db.enable_wal()`` and ``db.disable_wal()`` raise a ``sqlite_utils.db.TransactionError`` if called while a transaction is open, because changing the journal mode would commit it as a side effect. @@ -409,9 +438,11 @@ Two related safeguards to be aware of: Supported connection modes -------------------------- -``db.atomic()`` and the automatic per-method transactions require a connection in Python's default transaction handling mode. Passing a connection created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` or ``autocommit=False`` options to ``Database()`` raises a ``sqlite_utils.db.TransactionError``. +``db.atomic()`` and the automatic per-method transactions currently require a connection using Python's legacy transaction control mode (``sqlite3.LEGACY_TRANSACTION_CONTROL`` on Python 3.12 and later). Passing a connection created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` or ``autocommit=False`` options to ``Database()`` raises a ``sqlite_utils.db.TransactionError``. -This is because ``commit()`` and ``rollback()`` behave differently on those connections - under ``autocommit=True`` they are documented no-ops - which would cause every write made by this library to be silently discarded when the connection closed, rather than failing loudly. +Connections using ``autocommit=False`` are not supported because Python keeps a transaction open continuously. sqlite-utils uses ``Connection.in_transaction`` to distinguish its own transactions from transactions opened by its caller, and that distinction is not available in this mode. + +Connections using ``autocommit=True`` are also currently rejected because sqlite-utils has not formally exposed that as a supported configuration. .. _python_api_table: From 6531a57863ce23d502e504fd8fcd375fbe5cbb7f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 11 Jul 2026 16:28:36 -0700 Subject: [PATCH 17/26] Delete obsolete test_memory_attribute_for_existing_connection test Refs https://github.com/simonw/sqlite-utils/issues/789#issuecomment-4949146177 Closes #789 --- tests/test_constructor.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/test_constructor.py b/tests/test_constructor.py index 7428dcf..a619fba 100644 --- a/tests/test_constructor.py +++ b/tests/test_constructor.py @@ -111,10 +111,3 @@ def test_memory_attribute_for_file_path(tmpdir): db = Database(str(tmpdir / "file.db")) assert db.memory is False assert db.memory_name is None - - -def test_memory_attribute_for_existing_connection(): - conn = sqlite3.connect(":memory:") - db = Database(conn) - assert db.memory is False - assert db.memory_name is None From dc61f75a0ba84bca00f88da374df29610e8164cb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 11 Jul 2026 16:42:59 -0700 Subject: [PATCH 18/26] sqlite-utils upsert optional --pk in changelog --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index d2c07da..7333b26 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,7 @@ Unreleased - ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for :ref:`providing a block of Python 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`) - ``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 `. 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`) - New ``table.drop_index(name)`` method and ``sqlite-utils drop-index`` command for dropping an index by name. Both accept ``ignore=True``/``--ignore`` to ignore a missing index. (:issue:`626`) +- ``sqlite-utils upsert`` can now infer the primary key of an existing table, so ``--pk`` can be omitted when upserting into a table that already has a primary key. .. _v4_0: From b74b72703588863464880232e596001d958df180 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 11 Jul 2026 16:43:37 -0700 Subject: [PATCH 19/26] .transform(strict=) and sqlite-utils transform --strict/--no-strict (#788) * .transform(strict=) and sqlite-utils transform --strict/--no-strict Closes #787 --- docs/changelog.rst | 2 ++ docs/cli-reference.rst | 2 ++ docs/cli.rst | 8 +++++- docs/python-api.rst | 23 +++++++++++++++ sqlite_utils/cli.py | 8 ++++++ sqlite_utils/db.py | 13 ++++++++- tests/test_cli.py | 59 +++++++++++++++++++++++++++++++++++++ tests/test_transform.py | 64 +++++++++++++++++++++++++++++++++++++---- 8 files changed, 171 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7333b26..61f8381 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,8 @@ Unreleased ---------- +- ``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`) +- 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`) - ``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`) - ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for :ref:`providing a block of Python 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`) - ``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 `. 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`) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 71e8377..9fafe28 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -508,6 +508,8 @@ See :ref:`cli_transform_table`. Add a foreign key constraint from a column to another table with another column --drop-foreign-key TEXT Drop foreign key constraint for this column + --strict / --no-strict Enable or disable STRICT mode (default: + preserve current mode) --sql Output SQL without executing it --load-extension TEXT Path to SQLite extension, with optional :entrypoint diff --git a/docs/cli.rst b/docs/cli.rst index c446a72..dce36d9 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -2182,7 +2182,7 @@ Use ``--ignore`` to ignore the error if the table does not exist. Transforming tables =================== -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. +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. .. code-block:: bash @@ -2228,6 +2228,12 @@ Every option for this table (with the exception of ``--pk-none``) can be specifi ``--add-foreign-key column other_table other_column`` Add a foreign key constraint to ``column`` pointing to ``other_table.other_column``. +``--strict`` + Convert the table to a `SQLite STRICT table `__. 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. + +``--no-strict`` + Convert a strict table back to a regular non-strict table. + 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: .. code-block:: bash diff --git a/docs/python-api.rst b/docs/python-api.rst index d444806..8b07677 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -1753,6 +1753,29 @@ To alter the type of a column, use the ``types=`` argument: See :ref:`python_api_add_column` for a list of available types. +.. _python_api_transform_strict: + +Changing strict mode +-------------------- + +The optional ``strict=`` parameter can change whether a table uses `SQLite STRICT mode `__. Pass ``strict=True`` to convert a regular table to a strict table: + +.. code-block:: python + + table.transform(strict=True) + +Pass ``strict=False`` to convert a strict table back to a regular non-strict table: + +.. code-block:: python + + table.transform(strict=False) + +The default is ``strict=None``, which preserves the table's existing strict mode. + +Passing ``strict=True`` raises ``sqlite_utils.db.TransformError`` if the available SQLite version does not support strict tables. + +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. + .. _python_api_transform_rename_columns: Renaming columns diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 7fab72b..e0b8969 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -2718,6 +2718,11 @@ def schema( multiple=True, help="Drop foreign key constraint for this column", ) +@click.option( + "--strict/--no-strict", + default=None, + help="Enable or disable STRICT mode (default: preserve current mode)", +) @click.option("--sql", is_flag=True, help="Output SQL without executing it") @load_extension_option def transform( @@ -2735,6 +2740,7 @@ def transform( default_none, add_foreign_keys, drop_foreign_keys, + strict, sql, load_extension, ): @@ -2796,6 +2802,7 @@ def transform( defaults=default_dict, drop_foreign_keys=drop_foreign_keys_value, add_foreign_keys=add_foreign_keys_value, + strict=strict, ): click.echo(line) else: @@ -2809,6 +2816,7 @@ def transform( defaults=default_dict, drop_foreign_keys=drop_foreign_keys_value, add_foreign_keys=add_foreign_keys_value, + strict=strict, ) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 62e3656..d709fb9 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2514,6 +2514,7 @@ class Table(Queryable): foreign_keys: Optional[ForeignKeysType] = None, column_order: Optional[List[str]] = None, keep_table: Optional[str] = None, + strict: Optional[bool] = None, ) -> "Table": """ Apply an advanced alter table, including operations that are not supported by @@ -2536,6 +2537,8 @@ class Table(Queryable): to use when creating the table :param keep_table: If specified, the existing table will be renamed to this and will not be dropped + :param strict: Set to ``True`` to make the table strict or ``False`` to make it + non-strict. Defaults to ``None``, which preserves the existing strict mode. """ if not self.exists(): raise ValueError("Cannot transform a table that doesn't exist yet") @@ -2551,6 +2554,7 @@ class Table(Queryable): foreign_keys=foreign_keys, column_order=column_order, keep_table=keep_table, + strict=strict, ) pragma_foreign_keys_was_on = bool( self.db.execute("PRAGMA foreign_keys").fetchone()[0] @@ -2587,6 +2591,8 @@ class Table(Queryable): self.db.execute("PRAGMA defer_foreign_keys=OFF;") if should_disable_foreign_keys: self.db.execute("PRAGMA foreign_keys=1;") + if strict is not None: + self._defaults["strict"] = strict return self def transform_sql( @@ -2604,6 +2610,7 @@ class Table(Queryable): column_order: Optional[List[str]] = None, tmp_suffix: Optional[str] = None, keep_table: Optional[str] = None, + strict: Optional[bool] = None, ) -> List[str]: """ Return a list of SQL statements that should be executed in order to apply this transformation. @@ -2624,7 +2631,11 @@ class Table(Queryable): :param tmp_suffix: Suffix to use for the temporary table name :param keep_table: If specified, the existing table will be renamed to this and will not be dropped + :param strict: Set to ``True`` to make the table strict or ``False`` to make it + non-strict. Defaults to ``None``, which preserves the existing strict mode. """ + if strict is True and not self.db.supports_strict: + raise TransformError("SQLite does not support STRICT tables") types = types or {} rename = rename or {} drop = drop or set() @@ -2806,7 +2817,7 @@ class Table(Queryable): defaults=create_table_defaults, foreign_keys=create_table_foreign_keys, column_order=column_order, - strict=self.strict, + strict=self.strict if strict is None else strict, ).strip() ) diff --git a/tests/test_cli.py b/tests/test_cli.py index a828c70..a2135b0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ from sqlite_utils.db import Index, ForeignKey from click.testing import CliRunner from pathlib import Path import subprocess +import sqlite3 import sys import json import os @@ -1939,6 +1940,64 @@ def test_transform_sql(db_path): assert db["dogs"].schema == original_schema +@pytest.mark.parametrize( + "initial_strict,args,expected_strict", + ( + (False, [], False), + (True, [], True), + (False, ["--strict"], True), + (True, ["--no-strict"], False), + ), +) +def test_transform_strict_option(db_path, initial_strict, args, expected_strict): + db = Database(db_path) + if not db.supports_strict: + pytest.skip("SQLite version does not support strict tables") + db["dogs"].create({"id": int}, strict=initial_strict) + + result = CliRunner().invoke(cli.cli, ["transform", db_path, "dogs"] + args) + + assert result.exit_code == 0, result.output + assert db["dogs"].strict is expected_strict + + +@pytest.mark.parametrize( + "initial_strict,flag,sql_is_strict", + ( + (False, "--strict", True), + (True, "--no-strict", False), + ), +) +def test_transform_strict_option_sql(db_path, initial_strict, flag, sql_is_strict): + db = Database(db_path) + if not db.supports_strict: + pytest.skip("SQLite version does not support strict tables") + db["dogs"].create({"id": int}, strict=initial_strict) + + result = CliRunner().invoke(cli.cli, ["transform", db_path, "dogs", flag, "--sql"]) + + assert result.exit_code == 0, result.output + assert (") STRICT;" in result.output) is sql_is_strict + assert db["dogs"].strict is initial_strict + + +def test_transform_strict_option_with_invalid_data(db_path): + db = Database(db_path) + if not db.supports_strict: + pytest.skip("SQLite version does not support strict tables") + dogs = db["dogs"] + dogs.create({"id": int}) + dogs.insert({"id": "not-an-integer"}) + + result = CliRunner().invoke(cli.cli, ["transform", db_path, "dogs", "--strict"]) + + assert result.exit_code == 1 + assert isinstance(result.exception, sqlite3.IntegrityError) + assert dogs.strict is False + assert list(dogs.rows) == [{"id": "not-an-integer"}] + assert not any(name.startswith("dogs_new_") for name in db.table_names()) + + @pytest.mark.parametrize( "extra_args,expected_schema", ( diff --git a/tests/test_transform.py b/tests/test_transform.py index 71518be..f0f5019 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -1,3 +1,5 @@ +import sqlite3 + from sqlite_utils.db import ForeignKey, TransformError from sqlite_utils.utils import OperationalError import pytest @@ -566,13 +568,63 @@ def test_transform_preserves_rowids(fresh_db, table_type): assert previous_rows == next_rows -@pytest.mark.parametrize("strict", (False, True)) -def test_transform_strict(fresh_db, strict): - dogs = fresh_db.table("dogs", strict=strict) +@pytest.mark.parametrize( + "initial_strict,transform_strict,expected_strict", + ( + (False, None, False), + (True, None, True), + (False, True, True), + (True, False, False), + ), +) +def test_transform_strict(fresh_db, initial_strict, transform_strict, expected_strict): + if not fresh_db.supports_strict: + pytest.skip("SQLite version does not support strict tables") + dogs = fresh_db.table("dogs", strict=initial_strict) dogs.insert({"id": 1, "name": "Cleo"}) - assert dogs.strict == strict or not fresh_db.supports_strict - dogs.transform(not_null={"name"}) - assert dogs.strict == strict or not fresh_db.supports_strict + assert dogs.strict is initial_strict + dogs.transform(strict=transform_strict) + assert dogs.strict is expected_strict + + +def test_transform_to_strict_with_invalid_data(fresh_db): + if not fresh_db.supports_strict: + pytest.skip("SQLite version does not support strict tables") + dogs = fresh_db["dogs"] + dogs.create({"id": int}) + dogs.insert({"id": "not-an-integer"}) + + with pytest.raises(sqlite3.IntegrityError): + dogs.transform(strict=True) + + assert dogs.strict is False + assert list(dogs.rows) == [{"id": "not-an-integer"}] + assert fresh_db.table_names() == ["dogs"] + + +def test_transform_strict_updates_default(fresh_db): + if not fresh_db.supports_strict: + pytest.skip("SQLite version does not support strict tables") + table = fresh_db.table("items", strict=True) + table.create({"id": int}) + + table.transform(strict=False) + assert table.strict is False + + table.create({"id": int}, replace=True) + assert table.strict is False + + +@pytest.mark.parametrize("method_name", ("transform", "transform_sql")) +def test_transform_to_strict_not_supported(fresh_db, method_name): + table = fresh_db["items"] + table.create({"id": int}) + fresh_db._supports_strict = False + + with pytest.raises(TransformError, match="SQLite does not support STRICT tables"): + getattr(table, method_name)(strict=True) + + assert table.strict is False @pytest.mark.parametrize( From 57c16173912e00c639b9620cadc4dfe475ddb273 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 11 Jul 2026 16:48:41 -0700 Subject: [PATCH 20/26] Release 4.1 Refs #131, #626, #684, #765, #787 --- docs/changelog.rst | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 61f8381..5b9355f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,18 +4,18 @@ Changelog =========== -.. _v_unreleased: +.. _v4_1: -Unreleased ----------- +4.1 (2026-07-11) +---------------- -- ``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`) -- 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`) -- ``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`) - ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for :ref:`providing a block of Python 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`) - ``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 `. 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`) - New ``table.drop_index(name)`` method and ``sqlite-utils drop-index`` command for dropping an index by name. Both accept ``ignore=True``/``--ignore`` to ignore a missing index. (:issue:`626`) +- ``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`) - ``sqlite-utils upsert`` can now infer the primary key of an existing table, so ``--pk`` can be omitted when upserting into a table that already has a primary key. +- ``table.transform()`` and ``table.transform_sql()`` now accept ``strict=True`` or ``strict=False`` to change a table's `SQLite strict mode `__. Omitting the option preserves the existing mode. (:issue:`787`) +- The ``sqlite-utils transform`` command now accepts ``--strict`` and ``--no-strict`` to change a table's strict mode. (:issue:`787`) .. _v4_0: diff --git a/pyproject.toml b/pyproject.toml index a3a42a9..971f5a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sqlite-utils" -version = "4.0" +version = "4.1" description = "CLI tool and Python library for manipulating SQLite databases" readme = { file = "README.md", content-type = "text/markdown" } authors = [ From 5e8822efd27da9bc8495990f1decadea5f2b3167 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 11 Jul 2026 18:52:09 -0700 Subject: [PATCH 21/26] Clarify usage of named parameters in CLI documentation --- docs/cli.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.rst b/docs/cli.rst index dce36d9..8f65f45 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -361,7 +361,7 @@ To return the first column of each result as raw data, separated by newlines, us Using named parameters ---------------------- -You can pass named parameters to the query using ``-p``: +You can pass named parameters to the query using ``-p name value``: .. code-block:: bash From 3f0471701b5f8c7de888a467020e5dd34310ce9a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 11 Jul 2026 21:45:21 -0700 Subject: [PATCH 22/26] Add cross-reference notes between CLI and Python API documentation (#791) --- docs/cli.rst | 96 +++++++++++++++++++++++++++++++++++++ docs/python-api.rst | 114 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) diff --git a/docs/cli.rst b/docs/cli.rst index 8f65f45..2e506dd 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -424,6 +424,9 @@ The ``--functions`` option can be used multiple times to load functions from mul from urllib.parse import urlparse return urlparse(url).path' +.. note:: + In Python: :ref:`db.register_function() ` + .. _cli_query_extensions: SQLite extensions @@ -1024,6 +1027,9 @@ To show more than 10 common values, use ``--common-limit 20``. To skip the most sqlite-utils analyze-tables github.db tags --common-limit 20 --no-least +.. note:: + In Python: :ref:`table.analyze_column() ` CLI reference: :ref:`sqlite-utils analyze-tables ` + .. _cli_analyze_tables_save: Saving the analyzed table details @@ -1191,6 +1197,9 @@ You can delete all the existing rows in the table before inserting the new recor You can add the ``--analyze`` option to run ``ANALYZE`` against the table after the rows have been inserted. +.. note:: + In Python: :ref:`table.insert_all() ` CLI reference: :ref:`sqlite-utils insert ` + .. _cli_inserting_data_binary: Inserting binary data @@ -1615,6 +1624,9 @@ To replace a dog with in ID of 2 with a new record, run the following: echo '{"id": 2, "name": "Pancakes", "age": 3}' | \ sqlite-utils insert dogs.db dogs - --pk=id --replace +.. note:: + In Python: :ref:`table.insert(..., replace=True) ` CLI reference: :ref:`sqlite-utils insert ` + .. _cli_upsert: Upserting data @@ -1641,6 +1653,9 @@ The command will fail if you reference columns that do not exist on the table. T ``upsert`` in sqlite-utils 1.x worked like ``insert ... --replace`` does in 2.x. See `issue #66 `__ for details of this change. +.. note:: + In Python: :ref:`table.upsert() ` CLI reference: :ref:`sqlite-utils upsert ` + .. _cli_bulk: Executing SQL in bulk @@ -1842,6 +1857,9 @@ You can include named parameters in your where clause and populate them using on The ``--dry-run`` option will output a preview of the conversion against the first ten rows, without modifying the database. +.. note:: + In Python: :ref:`table.convert() ` CLI reference: :ref:`sqlite-utils convert ` + .. _cli_convert_import: Importing additional modules @@ -2140,6 +2158,9 @@ If a table with the same name already exists, you will get an error. You can cho You can also pass ``--transform`` to transform the existing table to match the new schema. See :ref:`python_api_explicit_create` in the Python library documentation for details of how this option works. +.. note:: + In Python: :ref:`table.create() ` CLI reference: :ref:`sqlite-utils create-table ` + .. _cli_renaming_tables: Renaming a table @@ -2153,6 +2174,9 @@ Yo ucan rename a table using the ``rename-table`` command: Pass ``--ignore`` to ignore any errors caused by the table not existing, or the new name already being in use. +.. note:: + In Python: :ref:`db.rename_table() ` CLI reference: :ref:`sqlite-utils rename-table ` + .. _cli_duplicate_table: Duplicating tables @@ -2164,6 +2188,9 @@ The ``duplicate`` command duplicates a table - creating a new table with the sam sqlite-utils duplicate books.db authors authors_copy +.. note:: + In Python: :ref:`table.duplicate() ` CLI reference: :ref:`sqlite-utils duplicate ` + .. _cli_drop_table: Dropping tables @@ -2177,6 +2204,9 @@ You can drop a table using the ``drop-table`` command: Use ``--ignore`` to ignore the error if the table does not exist. +.. note:: + In Python: :ref:`table.drop() ` CLI reference: :ref:`sqlite-utils drop-table ` + .. _cli_transform_table: Transforming tables @@ -2260,6 +2290,9 @@ If you want to see the SQL that will be executed to make the change without actu DROP TABLE "roadside_attractions"; ALTER TABLE "roadside_attractions_new_4033a60276b9" RENAME TO "roadside_attractions"; +.. note:: + In Python: :ref:`table.transform() ` CLI reference: :ref:`sqlite-utils transform ` + .. _cli_transform_table_add_primary_key_to_rowid: Adding a primary key to a rowid table @@ -2437,6 +2470,9 @@ After running the above, the command ``sqlite-utils schema global.db`` reveals t CREATE UNIQUE INDEX "idx_countries_country_name" ON "countries" ("country", "name"); +.. note:: + In Python: :ref:`table.extract() ` CLI reference: :ref:`sqlite-utils extract ` + .. _cli_create_view: Creating views @@ -2458,6 +2494,9 @@ You can create a view using the ``create-view`` command: Use ``--replace`` to replace an existing view of the same name, and ``--ignore`` to do nothing if a view already exists. +.. note:: + In Python: :ref:`db.create_view() ` CLI reference: :ref:`sqlite-utils create-view ` + .. _cli_drop_view: Dropping views @@ -2471,6 +2510,9 @@ You can drop a view using the ``drop-view`` command: Use ``--ignore`` to ignore the error if the view does not exist. +.. note:: + In Python: :ref:`view.drop() ` CLI reference: :ref:`sqlite-utils drop-view ` + .. _cli_add_column: Adding columns @@ -2511,6 +2553,9 @@ You can set a ``NOT NULL DEFAULT 'x'`` constraint on the new column using ``--no sqlite-utils add-column mydb.db dogs friends_count integer --not-null-default 0 +.. note:: + In Python: :ref:`table.add_column() ` CLI reference: :ref:`sqlite-utils add-column ` + .. _cli_add_column_alter: Adding columns automatically on insert/update @@ -2522,6 +2567,9 @@ You can use the ``--alter`` option to automatically add new columns if the data sqlite-utils insert dogs.db dogs new-dogs.json --pk=id --alter +.. note:: + In Python: :ref:`table.insert(..., alter=True) ` + .. _cli_add_foreign_key: Adding foreign key constraints @@ -2549,6 +2597,9 @@ Add ``--ignore`` to ignore an existing foreign key (as opposed to returning an e See :ref:`python_api_add_foreign_key` in the Python API documentation for further details, including how the automatic table guessing mechanism works. +.. note:: + In Python: :ref:`table.add_foreign_key() ` CLI reference: :ref:`sqlite-utils add-foreign-key ` + .. _cli_add_foreign_keys: Adding multiple foreign keys at once @@ -2564,6 +2615,9 @@ Adding a foreign key requires a ``VACUUM``. On large databases this can be an ex When you are using this command each foreign key needs to be defined in full, as four arguments - the table, column, other table and other column. +.. note:: + In Python: :ref:`db.add_foreign_keys() ` CLI reference: :ref:`sqlite-utils add-foreign-keys ` + .. _cli_index_foreign_keys: Adding indexes for all foreign keys @@ -2575,6 +2629,9 @@ If you want to ensure that every foreign key column in your database has a corre sqlite-utils index-foreign-keys books.db +.. note:: + In Python: :ref:`db.index_foreign_keys() ` CLI reference: :ref:`sqlite-utils index-foreign-keys ` + .. _cli_defaults_not_null: Setting defaults and not null constraints @@ -2590,6 +2647,9 @@ You can use the ``--not-null`` and ``--default`` options (to both ``insert`` and --default age 2 \ --default score 5 +.. note:: + In Python: :ref:`not_null= and defaults= arguments ` + .. _cli_create_index: Creating indexes @@ -2621,6 +2681,9 @@ If your column names are already prefixed with a hyphen you'll need to manually Add the ``--analyze`` option to run ``ANALYZE`` against the index after it has been created. +.. note:: + In Python: :ref:`table.create_index() ` CLI reference: :ref:`sqlite-utils create-index ` + .. _cli_drop_index: Dropping indexes @@ -2634,6 +2697,9 @@ You can drop an index from an existing table using the ``drop-index`` command: Use ``--ignore`` to ignore the error if the index does not exist on that table. +.. note:: + In Python: :ref:`table.drop_index() ` CLI reference: :ref:`sqlite-utils drop-index ` + .. _cli_fts: Configuring full-text search @@ -2687,6 +2753,9 @@ You can rebuild every FTS table by running ``rebuild-fts`` without passing any t sqlite-utils rebuild-fts mydb.db +.. note:: + In Python: :ref:`table.enable_fts() ` CLI reference: :ref:`sqlite-utils enable-fts ` + .. _cli_search: Executing searches @@ -2743,6 +2812,9 @@ Use the ``--sql`` option to output the SQL that would be executed, rather than r order by "documents_fts".rank +.. note:: + In Python: :ref:`table.search() ` CLI reference: :ref:`sqlite-utils search ` + .. _cli_enable_counts: Enabling cached counts @@ -2766,6 +2838,9 @@ If the ``_counts`` table ever becomes out-of-sync with the actual table counts y sqlite-utils reset-counts mydb.db +.. note:: + In Python: :ref:`table.enable_counts() ` CLI reference: :ref:`sqlite-utils enable-counts ` + .. _cli_analyze: Optimizing index usage with ANALYZE @@ -2789,6 +2864,9 @@ You can run it against specific tables, or against specific named indexes, by pa You can also run ``ANALYZE`` as part of another command using the ``--analyze`` option. This is supported by the ``create-index``, ``insert`` and ``upsert`` commands. +.. note:: + In Python: :ref:`db.analyze() ` CLI reference: :ref:`sqlite-utils analyze ` + .. _cli_vacuum: Vacuum @@ -2800,6 +2878,9 @@ You can run VACUUM to optimize your database like so: sqlite-utils vacuum mydb.db +.. note:: + In Python: :ref:`db.vacuum() ` CLI reference: :ref:`sqlite-utils vacuum ` + .. _cli_optimize: Optimize @@ -2823,6 +2904,9 @@ To optimize specific tables rather than every FTS table, pass those tables as ex sqlite-utils optimize mydb.db table_1 table_2 +.. note:: + In Python: :ref:`table.optimize() ` CLI reference: :ref:`sqlite-utils optimize ` + .. _cli_wal: WAL mode @@ -2842,6 +2926,9 @@ You can disable WAL mode using ``disable-wal``: Both of these commands accept one or more database files as arguments. +.. note:: + In Python: :ref:`db.enable_wal() and db.disable_wal() ` CLI reference: :ref:`sqlite-utils enable-wal ` + .. _cli_dump: Dumping the database to SQL @@ -2857,6 +2944,9 @@ The ``dump`` command outputs a SQL dump of the schema and full contents of the s ... COMMIT; +.. note:: + In Python: :ref:`db.iterdump() ` CLI reference: :ref:`sqlite-utils dump ` + .. _cli_load_extension: Loading SQLite extensions @@ -2904,6 +2994,9 @@ Eight (case-insensitive) types are allowed: * GEOMETRYCOLLECTION * GEOMETRY +.. note:: + In Python: :ref:`table.add_geometry_column() ` CLI reference: :ref:`sqlite-utils add-geometry-column ` + .. _cli_spatialite_indexes: Adding spatial indexes @@ -2917,6 +3010,9 @@ Once you have a geometry column, you can speed up bounding box queries by adding See this `SpatiaLite Cookbook recipe `__ for examples of how to use a spatial index. +.. note:: + In Python: :ref:`table.create_spatial_index() ` CLI reference: :ref:`sqlite-utils create-spatial-index ` + .. _cli_install: Installing packages diff --git a/docs/python-api.rst b/docs/python-api.rst index 8b07677..1ed238e 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -184,6 +184,9 @@ You can attach an additional database using the ``.attach()`` method, providing You can reference tables in the attached database using the alias value you passed to ``db.attach(alias, filepath)`` as a prefix, for example the ``second.table_in_second`` reference in the SQL query above. +.. note:: + In the CLI: :ref:`sqlite-utils --attach ` + .. _python_api_tracing: Tracing queries @@ -254,6 +257,9 @@ If a query returns more than one column with the same name - a join between two A suffix that would collide with another column in the query is skipped - ``select 1 as id, 2 as id, 3 as id_2`` returns ``{'id': 1, 'id_3': 2, 'id_2': 3}``. The same renaming is applied by ``table.rows_where()`` and ``table.search()``. +.. note:: + In the CLI: :ref:`sqlite-utils query ` + .. _python_api_execute: db.execute(sql, params) @@ -497,6 +503,9 @@ You can also iterate through the table objects themselves using the ``.tables`` >>> db.tables [] +.. note:: + In the CLI: :ref:`sqlite-utils tables ` + .. _python_api_views: Listing views @@ -522,6 +531,9 @@ View objects are similar to Table objects, except that any attempts to insert or * ``rows_where(where, where_args, order_by, select)`` * ``drop()`` +.. note:: + In the CLI: :ref:`sqlite-utils views ` + .. _python_api_rows: Listing rows @@ -577,6 +589,9 @@ This method also accepts ``offset=`` and ``limit=`` arguments, for specifying an ... print(row) {'id': 1, 'age': 4, 'name': 'Cleo'} +.. note:: + In the CLI: :ref:`sqlite-utils rows ` + .. _python_api_rows_count_where: Counting rows @@ -661,6 +676,9 @@ The ``db.schema`` property returns the full SQL schema for the database as a str "name" TEXT ); +.. note:: + In the CLI: :ref:`sqlite-utils schema ` + .. _python_api_creating_tables: Creating tables @@ -809,6 +827,9 @@ You can pass ``strict=True`` to create a table in ``STRICT`` mode: "name": str, }, strict=True) +.. note:: + In the CLI: :ref:`sqlite-utils create-table ` + .. _python_api_compound_primary_keys: Compound primary keys @@ -989,6 +1010,9 @@ Here's an example that uses these features: # ) +.. note:: + In the CLI: :ref:`sqlite-utils insert --not-null and --default ` + .. _python_api_rename_table: Renaming a table @@ -1006,6 +1030,9 @@ This executes the following SQL: ALTER TABLE [my_table] RENAME TO [new_name_for_my_table] +.. note:: + In the CLI: :ref:`sqlite-utils rename-table ` + .. _python_api_duplicate: Duplicating tables @@ -1021,6 +1048,9 @@ The new ``authors_copy`` table will now contain a duplicate copy of the data fro This method raises ``sqlite_utils.db.NoTable`` if the table does not exist. +.. note:: + In the CLI: :ref:`sqlite-utils duplicate ` + .. _python_api_bulk_inserts: Bulk inserts @@ -1063,6 +1093,9 @@ You can delete all the existing rows in the table before inserting the new recor Pass ``analyze=True`` to run ``ANALYZE`` against the table after inserting the new records. +.. note:: + In the CLI: :ref:`sqlite-utils insert ` + .. _python_api_insert_lists: Inserting data from a list or tuple iterator @@ -1140,6 +1173,9 @@ To replace any existing records that have a matching primary key, use the ``repl .. note:: Prior to sqlite-utils 2.0 the ``.upsert()`` and ``.upsert_all()`` methods worked the same way as ``.insert(replace=True)`` does today. See :ref:`python_api_upsert` for the new behaviour of those methods introduced in 2.0. +.. note:: + In the CLI: :ref:`sqlite-utils insert --replace ` + .. _python_api_update: Updating a specific record @@ -1225,6 +1261,9 @@ Every record passed to ``upsert()`` or ``upsert_all()`` must include a value for .. note:: ``.upsert()`` and ``.upsert_all()`` in sqlite-utils 1.x worked like ``.insert(..., replace=True)`` and ``.insert_all(..., replace=True)`` do in 2.x. See `issue #66 `__ for details of this change. +.. note:: + In the CLI: :ref:`sqlite-utils upsert ` + .. _python_api_old_upsert: Alternative upserts using INSERT OR IGNORE @@ -1576,6 +1615,9 @@ You can set a ``NOT NULL DEFAULT 'x'`` constraint on the new column using ``not_ db.table("dogs").add_column("friends_count", int, not_null_default=0) +.. note:: + In the CLI: :ref:`sqlite-utils add-column ` + .. _python_api_add_column_alter: Adding columns automatically on insert/update @@ -1599,6 +1641,9 @@ You can insert or update data that includes new columns and have the table autom new_table = db.table("new_table", alter=True) new_table.insert({"name": "Gareth", "age": 32, "shoe_size": 11}) +.. note:: + In the CLI: :ref:`sqlite-utils insert --alter ` + .. _python_api_add_foreign_key: Adding foreign key constraints @@ -1657,6 +1702,9 @@ Use ``on_delete=`` and ``on_update=`` to specify ``ON DELETE`` and ``ON UPDATE`` This creates a foreign key with an ``ON DELETE CASCADE`` clause, so deleting an author will also delete their books (provided foreign key enforcement is enabled with ``PRAGMA foreign_keys = ON``). Valid actions are ``"SET NULL"``, ``"SET DEFAULT"``, ``"CASCADE"``, ``"RESTRICT"`` and the default ``"NO ACTION"``. +.. note:: + In the CLI: :ref:`sqlite-utils add-foreign-key ` + .. _python_api_add_foreign_keys: Adding multiple foreign key constraints at once @@ -1677,6 +1725,9 @@ This method runs the same checks as ``.add_foreign_keys()`` and will raise ``sql Foreign keys that already exist are silently skipped, so repeated calls are idempotent - but only if they match exactly. Requesting a foreign key that exists with different ``ON DELETE``/``ON UPDATE`` actions raises ``AlterError``: use ``table.transform()`` to change the actions of an existing foreign key. +.. note:: + In the CLI: :ref:`sqlite-utils add-foreign-keys ` + .. _python_api_index_foreign_keys: Adding indexes for all foreign keys @@ -1690,6 +1741,9 @@ If you want to ensure that every foreign key column in your database has a corre Compound foreign keys get a single composite index across their columns. +.. note:: + In the CLI: :ref:`sqlite-utils index-foreign-keys ` + .. _python_api_drop: Dropping a table or view @@ -1711,6 +1765,9 @@ Pass ``ignore=True`` if you want to ignore the error caused by the table or view db.table("my_table").drop(ignore=True) +.. note:: + In the CLI: :ref:`sqlite-utils drop-table ` and :ref:`sqlite-utils drop-view ` + .. _python_api_transform: Transforming a table @@ -1739,6 +1796,9 @@ To keep the original table around instead of dropping it, pass the ``keep_table= This method raises a ``sqlite_utils.db.TransformError`` exception if the table cannot be transformed, usually because there are existing constraints or indexes that are incompatible with modifications to the columns. +.. note:: + In the CLI: :ref:`sqlite-utils transform ` + .. _python_api_transform_alter_column_types: Altering column types @@ -2094,6 +2154,9 @@ This produces a lookup table like so: Rows where every extracted column is ``null`` are not extracted: no record is created for them in the lookup table and their foreign key column is left as ``null``. When extracting multiple columns, rows where at least one of the extracted columns has a value will be extracted as usual. +.. note:: + In the CLI: :ref:`sqlite-utils extract ` + .. _python_api_hash: Setting an ID based on the hash of the row contents @@ -2155,6 +2218,9 @@ You can pass ``ignore=True`` to silently ignore an existing view and do nothing, select * from dogs where is_good_dog = 1 """, replace=True) +.. note:: + In the CLI: :ref:`sqlite-utils create-view ` + Storing JSON ============ @@ -2273,6 +2339,9 @@ If you are using ``pysqlite3`` the underlying method may be missing. If you inst pip install sqlite-dump +.. note:: + In the CLI: :ref:`sqlite-utils dump ` + .. _python_api_introspection: Introspecting tables and views @@ -2472,6 +2541,9 @@ The ``.indexes`` property returns all indexes created for a table, as a list of Index(seq=4, name='"Street_Tree_List_qCaretaker"', unique=0, origin='c', partial=0, columns=['qCaretaker']), Index(seq=5, name='"Street_Tree_List_PlantType"', unique=0, origin='c', partial=0, columns=['PlantType'])] +.. note:: + In the CLI: :ref:`sqlite-utils indexes ` + .. _python_api_introspection_xindexes: .xindexes @@ -2515,6 +2587,9 @@ The ``.triggers`` property lists database triggers. It can be used on both datab >>> db.triggers ... similar output to db.table("authors").triggers +.. note:: + In the CLI: :ref:`sqlite-utils triggers ` + .. _python_api_introspection_triggers_dict: .triggers_dict @@ -2663,6 +2738,9 @@ To remove the FTS tables and triggers you created, use the ``disable_fts()`` tab db.table("dogs").disable_fts() +.. note:: + In the CLI: :ref:`sqlite-utils enable-fts ` + .. _python_api_quote_fts: Quoting characters for use in search @@ -2727,6 +2805,9 @@ To return just the title and published columns for three matches for ``"dog"`` w ): print(article) +.. note:: + In the CLI: :ref:`sqlite-utils search ` + .. _python_api_fts_search_sql: Building SQL queries with table.search_sql() @@ -2811,6 +2892,9 @@ This runs the following SQL:: INSERT INTO dogs_fts (dogs_fts) VALUES ("rebuild"); +.. note:: + In the CLI: :ref:`sqlite-utils rebuild-fts ` + .. _python_api_fts_optimize: Optimizing a full-text search table @@ -2826,6 +2910,9 @@ This runs the following SQL:: INSERT INTO dogs_fts (dogs_fts) VALUES ("optimize"); +.. note:: + In the CLI: :ref:`sqlite-utils optimize ` + .. _python_api_cached_table_counts: Cached table counts using triggers @@ -2888,6 +2975,9 @@ If the ``_counts`` table ever becomes out-of-sync with the actual table counts y db.reset_counts() +.. note:: + In the CLI: :ref:`sqlite-utils enable-counts ` + .. _python_api_create_index: Creating indexes @@ -2939,6 +3029,9 @@ You can drop an index from a table using ``.drop_index(index_name)``: Use ``ignore=True`` to ignore the error if the index does not exist. +.. note:: + In the CLI: :ref:`sqlite-utils create-index ` and :ref:`sqlite-utils drop-index ` + .. _python_api_analyze: Optimizing index usage with ANALYZE @@ -2966,6 +3059,9 @@ To run against all indexes attached to a specific table, you can either pass the db.table("dogs").analyze() +.. note:: + In the CLI: :ref:`sqlite-utils analyze ` + .. _python_api_vacuum: Vacuum @@ -2977,6 +3073,9 @@ You can optimize your database by running VACUUM against it like so: Database("my_database.db").vacuum() +.. note:: + In the CLI: :ref:`sqlite-utils vacuum ` + .. _python_api_wal: WAL mode @@ -3004,6 +3103,9 @@ You can check the current journal mode for a database using the ``journal_mode`` This will usually be ``wal`` or ``delete`` (meaning WAL is disabled), but can have other values - see the `PRAGMA journal_mode `__ documentation. +.. note:: + In the CLI: :ref:`sqlite-utils enable-wal and disable-wal ` + .. _python_api_suggest_column_types: Suggesting column types @@ -3144,6 +3246,9 @@ You can cause ``sqlite3`` to return more useful errors, including the traceback sqlite3.enable_callback_tracebacks(True) +.. note:: + In the CLI: :ref:`sqlite-utils query --functions ` + .. _python_api_quote: Quoting strings for use in SQL @@ -3276,6 +3381,9 @@ Initialize SpatiaLite .. automethod:: sqlite_utils.db.Database.init_spatialite :noindex: +.. note:: + In the CLI: :ref:`sqlite-utils create-database --init-spatialite ` + .. _python_api_gis_find_spatialite: Finding SpatiaLite @@ -3291,6 +3399,9 @@ Adding geometry columns .. automethod:: sqlite_utils.db.Table.add_geometry_column :noindex: +.. note:: + In the CLI: :ref:`sqlite-utils add-geometry-column ` + .. _python_api_gis_create_spatial_index: Creating a spatial index @@ -3298,3 +3409,6 @@ Creating a spatial index .. automethod:: sqlite_utils.db.Table.create_spatial_index :noindex: + +.. note:: + In the CLI: :ref:`sqlite-utils create-spatial-index ` From d71420065903ff54247b5062b8c6af6165b7e638 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 12 Jul 2026 05:00:32 -0700 Subject: [PATCH 23/26] Add test: transform does not cascade-delete referencing records (#792) > Add a test that covers what happens if you run transform against a table with ON CASCADE DELETE for one of its foreign keys - those records should not be deleted during the transform even though the table is dropped as part of that procedure --- tests/test_transform.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_transform.py b/tests/test_transform.py index f0f5019..3cc5ba6 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -432,6 +432,43 @@ def test_transform_verify_foreign_keys(fresh_db): assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0] +@pytest.mark.parametrize("use_pragma_foreign_keys", [False, True]) +def test_transform_on_delete_cascade_does_not_delete_records( + fresh_db, use_pragma_foreign_keys +): + # Transforming a table drops and recreates it - if another table references + # it with ON DELETE CASCADE and PRAGMA foreign_keys is on, that drop must + # not cascade and delete the referencing records + if use_pragma_foreign_keys: + fresh_db.conn.execute("PRAGMA foreign_keys=ON") + fresh_db.executescript(""" + CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT); + CREATE TABLE books ( + id INTEGER PRIMARY KEY, + title TEXT, + author_id INTEGER REFERENCES authors(id) ON DELETE CASCADE + ); + """) + fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"}) + fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1}) + # Transform the table on the other end of the cascading foreign key + fresh_db["authors"].transform(rename={"name": "author_name"}) + assert list(fresh_db["authors"].rows) == [ + {"id": 1, "author_name": "Ursula K. Le Guin"} + ] + assert list(fresh_db["books"].rows) == [ + {"id": 1, "title": "The Dispossessed", "author_id": 1} + ] + # Transforming the table with the cascading foreign key should not + # delete its records either + fresh_db["books"].transform(rename={"title": "book_title"}) + assert list(fresh_db["books"].rows) == [ + {"id": 1, "book_title": "The Dispossessed", "author_id": 1} + ] + if use_pragma_foreign_keys: + assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0] + + def test_transform_add_foreign_keys_from_scratch(fresh_db): _add_country_city_continent(fresh_db) fresh_db["places"].insert(_CAVEAU) From f66ddcb215e76dcbc1fa1dff4359f2ac3dc702e5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 12 Jul 2026 08:43:51 -0700 Subject: [PATCH 24/26] Transform now refuses to run inside a transaction if destructive foreign keys exist (#795) * Transform now refuses to run inside a transaction if destructive foreign keys exist Closes #794 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014StVTWQJpFhfZJK2CYVBwv --- docs/python-api.rst | 33 ++++++++++- sqlite_utils/db.py | 35 ++++++++++++ tests/test_transform.py | 124 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 190 insertions(+), 2 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 1ed238e..43b734d 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -434,9 +434,10 @@ The library will never commit a transaction you opened. If you call write method Prefer ``db.atomic()`` or ``db.begin()``, ``db.commit()`` and ``db.rollback()`` over mixing sqlite-utils transaction methods with calls to ``db.conn.commit()``, ``db.conn.rollback()`` or raw transaction-control SQL. Mixing the two layers makes it much harder to tell which layer owns the current transaction. -Two related safeguards to be aware of: +Some related safeguards to be aware of: - ``db.enable_wal()`` and ``db.disable_wal()`` raise a ``sqlite_utils.db.TransactionError`` if called while a transaction is open, because changing the journal mode would commit it as a side effect. +- ``table.transform()`` raises a ``sqlite_utils.db.TransactionError`` if called while a transaction is open with ``PRAGMA foreign_keys`` enabled and the table is referenced by foreign keys with destructive ``ON DELETE`` actions, because the pragma cannot be turned off mid-transaction to protect those referencing rows - see :ref:`python_api_transform_foreign_keys_transactions`. - Closing the database - explicitly with ``db.close()``, or by exiting a ``with Database(...) as db:`` block - rolls back any transaction that is still open, see :ref:`python_api_close`. .. _python_api_transactions_modes: @@ -1996,6 +1997,36 @@ If you want to do something more advanced, you can call the ``table.transform_sq This method will return a list of SQL statements that should be executed to implement the change. You can then make modifications to that SQL - or add additional SQL statements - before executing it yourself. +.. _python_api_transform_foreign_keys_transactions: + +Foreign keys and transactions +----------------------------- + +Because ``.transform()`` drops the old table, running it with ``PRAGMA foreign_keys`` enabled could fire ``ON DELETE`` actions on any tables that reference it - an inbound ``ON DELETE CASCADE`` foreign key would silently delete those referencing rows. To prevent this, ``.transform()`` turns ``PRAGMA foreign_keys`` off for the duration of the operation and restores it afterwards, running ``PRAGMA foreign_key_check`` before committing. + +``PRAGMA foreign_keys`` cannot be changed inside a transaction, so this protection is impossible if you call ``.transform()`` while a transaction is already open - for example inside a ``with db.atomic():`` block or after ``db.begin()``. If ``PRAGMA foreign_keys`` is on and another table references the table being transformed with a destructive ``ON DELETE`` action - ``CASCADE``, ``SET NULL`` or ``SET DEFAULT`` - the method will refuse to run and raise a ``sqlite_utils.db.TransactionError``: + +.. code-block:: python + + from sqlite_utils.db import TransactionError + + try: + with db.atomic(): + db["authors"].transform(types={"id": str}) + except TransactionError as ex: + print("Could not transform in transaction:", ex) + +To transform such a table either call ``.transform()`` outside of the transaction, or execute ``PRAGMA foreign_keys = off`` before opening it: + +.. code-block:: python + + db.execute("PRAGMA foreign_keys = off") + with db.atomic(): + db["authors"].transform(types={"id": str}) + db.execute("PRAGMA foreign_keys = on") + +Tables referenced by foreign keys without a destructive action (the default ``NO ACTION``, or ``RESTRICT``) can still be transformed inside a transaction - sqlite-utils uses ``PRAGMA defer_foreign_keys`` to postpone the foreign key checks until the transaction commits. + .. _python_api_extract: Extracting columns into a separate table diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index d709fb9..e97b7d9 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2522,6 +2522,11 @@ class Table(Queryable): See :ref:`python_api_transform` for full details. + Raises :py:class:`sqlite_utils.db.TransactionError` if called while a + transaction is open with ``PRAGMA foreign_keys`` enabled and the table + is referenced by foreign keys with destructive ``ON DELETE`` actions - + see :ref:`python_api_transform_foreign_keys_transactions`. + :param types: Columns that should have their type changed, for example ``{"weight": float}`` :param rename: Columns to rename, for example ``{"headline": "title"}`` :param drop: Columns to drop @@ -2566,6 +2571,36 @@ class Table(Queryable): should_defer_foreign_keys = ( pragma_foreign_keys_was_on and already_in_transaction ) + if should_defer_foreign_keys: + # PRAGMA foreign_keys is a no-op inside a transaction, and + # defer_foreign_keys only defers violation checks, not ON DELETE + # actions - so dropping the old table would still fire destructive + # actions on any tables that reference it. Refuse rather than + # silently modify or delete those rows. + destructive_fks = [ + (table.name, fk) + for table in self.db.tables + for fk in table.foreign_keys + if fk.other_table == self.name + and fk.on_delete in ("CASCADE", "SET NULL", "SET DEFAULT") + ] + if destructive_fks: + raise TransactionError( + "Cannot transform table {table} while a transaction is open: " + "PRAGMA foreign_keys cannot be changed inside a transaction, " + "and the table is referenced by foreign keys with ON DELETE " + "actions that would fire when the old table is dropped: " + "{fks}. Call transform() outside of the transaction, or " + 'execute "PRAGMA foreign_keys = off" before opening it.'.format( + table=self.name, + fks=", ".join( + "{}.{} (ON DELETE {})".format( + table_name, ", ".join(fk.columns), fk.on_delete + ) + for table_name, fk in destructive_fks + ), + ) + ) defer_foreign_keys_was_on = False try: if should_disable_foreign_keys: diff --git a/tests/test_transform.py b/tests/test_transform.py index 3cc5ba6..362f1ca 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -1,6 +1,6 @@ import sqlite3 -from sqlite_utils.db import ForeignKey, TransformError +from sqlite_utils.db import ForeignKey, TransactionError, TransformError from sqlite_utils.utils import OperationalError import pytest @@ -469,6 +469,128 @@ def test_transform_on_delete_cascade_does_not_delete_records( assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0] +@pytest.mark.parametrize("on_delete", ["CASCADE", "SET NULL", "SET DEFAULT", "cascade"]) +def test_transform_in_transaction_refuses_destructive_on_delete(fresh_db, on_delete): + # PRAGMA foreign_keys is a no-op inside a transaction, so transforming a + # table referenced by ON DELETE CASCADE / SET NULL / SET DEFAULT foreign + # keys inside an open transaction would fire those actions when the old + # table is dropped - transform() should refuse instead + fresh_db.conn.execute("PRAGMA foreign_keys=ON") + fresh_db.executescript(""" + CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT); + CREATE TABLE books ( + id INTEGER PRIMARY KEY, + title TEXT, + author_id INTEGER REFERENCES authors(id) ON DELETE {} + ); + """.format(on_delete)) + fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"}) + fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1}) + previous_schema = fresh_db["authors"].schema + with fresh_db.atomic(): + with pytest.raises(TransactionError) as excinfo: + fresh_db["authors"].transform(rename={"name": "author_name"}) + message = str(excinfo.value) + assert "books" in message + assert "ON DELETE {}".format(on_delete.upper()) in message + # Nothing should have changed + assert fresh_db["authors"].schema == previous_schema + assert list(fresh_db["books"].rows) == [ + {"id": 1, "title": "The Dispossessed", "author_id": 1} + ] + assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0] + + +def test_transform_in_transaction_refuses_self_referential_cascade(fresh_db): + # The copied table carries a foreign key referencing the original table + # name, so a self-referential cascade would wipe the copy too + fresh_db.conn.execute("PRAGMA foreign_keys=ON") + fresh_db.executescript(""" + CREATE TABLE categories ( + id INTEGER PRIMARY KEY, + name TEXT, + parent_id INTEGER REFERENCES categories(id) ON DELETE CASCADE + ); + """) + fresh_db["categories"].insert_all( + [ + {"id": 1, "name": "Fiction", "parent_id": None}, + {"id": 2, "name": "Science Fiction", "parent_id": 1}, + ] + ) + with fresh_db.atomic(): + with pytest.raises(TransactionError) as excinfo: + fresh_db["categories"].transform(rename={"name": "title"}) + assert "categories" in str(excinfo.value) + assert fresh_db["categories"].count == 2 + + +def test_transform_in_transaction_allowed_with_no_action_foreign_key(fresh_db): + # An inbound foreign key without a destructive ON DELETE action is safe + # inside a transaction thanks to PRAGMA defer_foreign_keys + fresh_db.conn.execute("PRAGMA foreign_keys=ON") + fresh_db.executescript(""" + CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT); + CREATE TABLE books ( + id INTEGER PRIMARY KEY, + title TEXT, + author_id INTEGER REFERENCES authors(id) + ); + """) + fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"}) + fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1}) + with fresh_db.atomic(): + fresh_db["authors"].transform(rename={"name": "author_name"}) + assert list(fresh_db["authors"].rows) == [ + {"id": 1, "author_name": "Ursula K. Le Guin"} + ] + assert list(fresh_db["books"].rows) == [ + {"id": 1, "title": "The Dispossessed", "author_id": 1} + ] + assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0] + + +def test_transform_in_transaction_allowed_for_child_table(fresh_db): + # The table being transformed only has an outbound foreign key - dropping + # it fires no ON DELETE actions, so this is allowed inside a transaction + fresh_db.conn.execute("PRAGMA foreign_keys=ON") + fresh_db.executescript(""" + CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT); + CREATE TABLE books ( + id INTEGER PRIMARY KEY, + title TEXT, + author_id INTEGER REFERENCES authors(id) ON DELETE CASCADE + ); + """) + fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"}) + fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1}) + with fresh_db.atomic(): + fresh_db["books"].transform(rename={"title": "book_title"}) + assert list(fresh_db["books"].rows) == [ + {"id": 1, "book_title": "The Dispossessed", "author_id": 1} + ] + + +def test_transform_in_transaction_allowed_with_foreign_keys_off(fresh_db): + # With PRAGMA foreign_keys off (the default) no cascades can fire, so + # transform inside a transaction is safe even with a CASCADE schema + fresh_db.executescript(""" + CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT); + CREATE TABLE books ( + id INTEGER PRIMARY KEY, + title TEXT, + author_id INTEGER REFERENCES authors(id) ON DELETE CASCADE + ); + """) + fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"}) + fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1}) + with fresh_db.atomic(): + fresh_db["authors"].transform(rename={"name": "author_name"}) + assert list(fresh_db["books"].rows) == [ + {"id": 1, "title": "The Dispossessed", "author_id": 1} + ] + + def test_transform_add_foreign_keys_from_scratch(fresh_db): _add_country_city_continent(fresh_db) fresh_db["places"].insert(_CAVEAU) From 458b3ab5b169eff1f8319c44a7c320c68f54d28b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 12 Jul 2026 13:52:14 -0700 Subject: [PATCH 25/26] Release 4.1.1 Refs #791, #792, #794, #795 --- docs/changelog.rst | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5b9355f..a8006c3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,13 @@ Changelog =========== +.. _v4_1_1: + +4.1.1 (2026-07-12) +------------------ + +- ``table.transform()`` now raises a ``TransactionError`` if called while a transaction is open with ``PRAGMA foreign_keys`` enabled and the table is referenced by foreign keys with destructive ``ON DELETE`` actions - ``CASCADE``, ``SET NULL`` or ``SET DEFAULT``. The pragma cannot be changed inside a transaction, so previously dropping the old table as part of the transform could fire those actions and silently delete or modify referencing rows. See :ref:`python_api_transform_foreign_keys_transactions` for details and workarounds. (:issue:`794`) +- The CLI and Python API documentation now cross-reference each other: CLI sections link to the equivalent Python API functionality and Python API sections link back to the corresponding CLI command. (:issue:`791`) .. _v4_1: 4.1 (2026-07-11) diff --git a/pyproject.toml b/pyproject.toml index 971f5a0..003322c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sqlite-utils" -version = "4.1" +version = "4.1.1" description = "CLI tool and Python library for manipulating SQLite databases" readme = { file = "README.md", content-type = "text/markdown" } authors = [ From a947dc673923ff6e95b41d3dfabe1cbd95e6de86 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 12 Jul 2026 17:14:01 -0700 Subject: [PATCH 26/26] Changelog now links to CLI and Python API in most recent entry --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index a8006c3..4c868f4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,7 +10,7 @@ ------------------ - ``table.transform()`` now raises a ``TransactionError`` if called while a transaction is open with ``PRAGMA foreign_keys`` enabled and the table is referenced by foreign keys with destructive ``ON DELETE`` actions - ``CASCADE``, ``SET NULL`` or ``SET DEFAULT``. The pragma cannot be changed inside a transaction, so previously dropping the old table as part of the transform could fire those actions and silently delete or modify referencing rows. See :ref:`python_api_transform_foreign_keys_transactions` for details and workarounds. (:issue:`794`) -- The CLI and Python API documentation now cross-reference each other: CLI sections link to the equivalent Python API functionality and Python API sections link back to the corresponding CLI command. (:issue:`791`) +- The :ref:`CLI ` and :ref:`Python API ` documentation now cross-reference each other: CLI sections link to the equivalent Python API functionality and Python API sections link back to the corresponding CLI command. (:issue:`791`) .. _v4_1: 4.1 (2026-07-11)