Refuse a SQLite column named rowid before the migration runs
A migration added a column named rowid, and the next query did not read the integer alias. On SQLite 3.46.1 the statement ALTER TABLE t ADD COLUMN rowid TEXT succeeded. After UPDATE t SET rowid = 'legacy', SELECT rowid returned the text legacy. SELECT oid on the same row returned the integer 1. SQLite 3.54.1 opened that file and returned the same pair. It would not add the column, and it would not drop it either.
That is the check a migration has to pass before it runs on the newer shell. The 9 October 2026 notes for 3.54.0 say ALTER TABLE raises an error on an attempt to add, drop, or rename a column whose name is ROWID, _ROWID_, or OID. The notes do not say what an older file does after the binary changes, and they do not say that CREATE TABLE still accepts the name. Both of those showed up when the same statements were run on the shell that will execute the migration and on the shell that wrote the file.
The integer and the column answer different questions
An ordinary SQLite table has an integer row identifier. The bare names rowid, oid, and _rowid_ refer to that integer unless a declared column takes one of those names. An INTEGER PRIMARY KEY column is that integer. A TEXT column that happens to be spelled rowid is a stored value. After the 3.46.1 migration, both shells agreed on which name meant which value:
rowid legacy
t.rowid legacy
oid 1
_rowid_ 1
typeof(rowid) was text. typeof(oid) was integer. A script that uses the bare name rowid as the stable identifier of a row will read the text column once that column exists. A script that uses oid will still read the integer. Those two scripts no longer name the same thing, and a version string does not show the disagreement. The schema does.
The comparison used two shells on Debian 13, Linux amd64. The distribution client was /usr/bin/sqlite3:
3.46.1 2024-08-13 09:16:08 c9c2ab54ba1f5f46360f1b4f35d849cd3f080e6fc2b6c60e91b16c63f69aalt1
The other shell came from the official linux-x64 tools archive for 3.54.1, sqlite-tools-linux-x64-3540100.zip. Its SHA3-256 matched the download page before it was unpacked:
555635de98534d87a67ee2e6e1b0d79f494b1b33b223477d3e7a8ea0c66e416f
./sqlite3 --version then printed the source id published for 3.54.1:
3.54.1 2026-10-16 14:45:20 555e07ef33f69a0957d24d8e555b702464a131d6f1607f4c3c1e4551e8e6ca76
SELECT sqlite_source_id() returned that same string. The 16 October 3.54.1 notes describe a 32-bit heap fix and other user-reported fixes. This run was 64-bit, and it did not include a separate 3.54.0 binary. The ALTER refusal is the 9 October behavior, observed here on the 16 October shell. A distribution package can carry either behavior under a version string that does not say 3.54. Read the source id from the binary the job will execute.
Both shells were invoked with -batch, so the startup banner is not part of the result. The databases were a memory database for the statement probes and a file copy for the rebuild. No production file was opened.
The refusal is in ALTER, not in CREATE
On 3.46.1, every one of these statements succeeded and left a declared column of that spelling:
ALTER TABLE t ADD COLUMN rowid TEXT;
ALTER TABLE t ADD COLUMN ROWID TEXT;
ALTER TABLE t ADD COLUMN Rowid TEXT;
ALTER TABLE t ADD COLUMN oid TEXT;
ALTER TABLE t ADD COLUMN OID TEXT;
ALTER TABLE t ADD COLUMN _rowid_ TEXT;
ALTER TABLE t ADD COLUMN _ROWID_ TEXT;
ALTER TABLE t ADD COLUMN "rowid" TEXT;
On 3.54.1 each one failed, the schema was unchanged, and the process exited 1. The message used the alias spelling, not the quotes:
Parse error near line 2: cannot add rowid alias: rowid
Parse error near line 2: cannot add rowid alias: ROWID
Parse error near line 2: cannot add rowid alias: Rowid
Parse error near line 2: cannot add rowid alias: oid
Parse error near line 2: cannot add rowid alias: _rowid_
The quoted form ADD COLUMN "rowid" produced cannot add rowid alias: rowid, the same text as the unquoted form. Quotes did not bypass the check. Case did not either. A name that is not one of the three aliases still works. ALTER TABLE t ADD COLUMN rowid2 TEXT and ALTER TABLE t ADD COLUMN note TEXT succeeded on both shells. A scanner that rejects every column whose name contains the letters rowid will reject rowid2, and this pair of shells did not.
Rename is the same gate, with a different message. ALTER TABLE t RENAME COLUMN name TO rowid succeeded on 3.46.1. On 3.54.1 it stopped with cannot rename to rowid alias: rowid, and .schema still showed name. Renaming the integer primary key itself is not a special case. This statement succeeded on 3.46.1 and produced CREATE TABLE t(rowid INTEGER PRIMARY KEY, name TEXT):
ALTER TABLE t RENAME COLUMN id TO rowid;
3.54.1 refused it with the same rename error. A migration that wants the conventional integer primary key name cannot get there with ALTER TABLE on this shell. It has to create the table with that name, or rebuild it.
CREATE TABLE is outside the refusal. Both shells accepted these statements:
CREATE TABLE t(rowid INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE t(rowid TEXT, name TEXT);
CREATE TABLE t(oid TEXT, name TEXT);
CREATE TABLE t(_rowid_ TEXT, name TEXT);
The integer-primary-key form is the alias, not a second column. On 3.54.1, after inserting one row, SELECT rowid returned 1 and typeof(rowid) was integer. The text form is the collision the ALTER gate is trying to stop, and CREATE still writes it. On 3.54.1, INSERT INTO t(rowid, name) VALUES ('legacy','alpha') followed by SELECT rowid, oid returned legacy and 1. A review that only greps migration files for ALTER TABLE will miss a CREATE TABLE that introduces the same name.
WITHOUT ROWID is also outside the refusal. A table declared WITHOUT ROWID has no integer alias under those names. Both shells accepted ALTER TABLE t ADD COLUMN rowid TEXT and ALTER TABLE t RENAME COLUMN name TO rowid on this table:
CREATE TABLE t(id TEXT PRIMARY KEY, name TEXT) WITHOUT ROWID;
The resulting schema kept WITHOUT ROWID and the column named rowid. Treating that name as an error on a WITHOUT ROWID table would reject a legal column. The check has to see the table kind, not only the identifier.
Renaming the table is a third operation. ALTER TABLE t RENAME TO rowid succeeded on 3.54.1. The schema became CREATE TABLE "rowid"(id INTEGER PRIMARY KEY). The new error is about a column alias, not about a table that happens to be called rowid.
Dropping a name that is not a declared column fails on both shells, and the message is the old one: no such column: "rowid". That message does not mean the 3.54 refusal is present. It means the table never had that column.
The upgrade does not remove the column
The file in this run was created with 3.46.1: one row, name set to alpha, and a text column rowid set to legacy. Opening it with 3.54.1 did not rewrite the schema. .schema still showed the text column. SELECT rowid, oid still returned legacy and 1.
ALTER TABLE t DROP COLUMN rowid on that file is where the two shells diverge. 3.46.1 dropped the column. The schema went back to CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT), and SELECT rowid returned the integer 1. 3.54.1 exited 1 with:
Parse error near line 1: cannot drop rowid alias: rowid
The schema and the text value were unchanged. A migration that “fixes” an older file by dropping the column will fail on the shell that introduced the refusal. The binary change prevents a new ALTER. It does not contain a cleanup of files that already have the column.
Rebuild the table on a copy
The cleanup that worked on 3.54.1 was a rebuild of a copy. The source file was left in place and checked again afterward. Its schema still contained the text column. On the copy, the shell ran:
CREATE TABLE t_new(id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO t_new(id, name) SELECT id, name FROM t;
DROP TABLE t;
ALTER TABLE t_new RENAME TO t;
.schema after that was CREATE TABLE "t"(id INTEGER PRIMARY KEY, name TEXT). SELECT id, name, rowid, typeof(rowid) returned 1|alpha|1|integer. The text value legacy was not copied, which is the point of naming the columns in the INSERT instead of using SELECT *. SELECT * would have carried the colliding column into the new table, and the new table would have been born with the same shadow.
This table had no index, trigger, view, or foreign key. A rebuild of a table that has those objects has to create them again. This run did not. Copy the file first, rebuild the copy, and compare a row count and a known key before replacing the original. The 3.54.1 shell will not drop the old column for you, and PRAGMA writable_schema is not a substitute that this run exercised.
If the goal is a table whose integer key is named rowid, create it that way. This statement succeeded on 3.54.1 and typeof(rowid) was integer:
CREATE TABLE t_new(rowid INTEGER PRIMARY KEY, name TEXT);
Do not try to reach that shape by renaming an existing integer primary key. That rename was refused.
The same shell changes other script habits
The rowid refusal is the schema gate. Other changes in this shell will break a script that still runs cleanly on 3.46.1, and they are visible in the same binary check.
A line that contains only go, GO, or / used to end a SQL statement. On 3.46.1 this input printed 42 and then 7:
SELECT 42
go
SELECT 7;
On 3.54.1 the shell joined the lines and stopped:
Parse error near line 1: near "SELECT": syntax error
SELECT 42 go SELECT 7;
^--- error here
/ produced SELECT 42 / SELECT 7 and the same class of error. GO was not a different terminator. It was folded into the statement the same way. A semicolon does not save the following line. SELECT 42; then a line go then SELECT 7; printed 42 on 3.54.1 and then failed with near "go". SELECT 7 did not run. The process exit was 1.
A one-statement file can hide this. Both shells, given SELECT 42 with no semicolon and no following statement, printed 42 at end of input. The decisive input is two statements separated by go or /. The 3.54.0 notes say those terminators are discontinued unless the shell is compiled with SQLITE_SHELL_LEGACY_COMMAND_TERMINATOR. The official 3.54.1 linux-x64 shell used here was not compiled that way. A distribution build could make a different choice. The two-statement input is the check, not the version string.
Date modifiers moved as well. On 3.46.1, date('2026-01-01','end of year'), date('2026-02-15','end of month'), and datetime('2026-06-15 13:45:00','end of day') returned no value. date('2026-10-26','weekday -1') returned no value. weekday 1 on that Monday returned 2026-10-26, because positive weekday numbers were already implemented and the date was already Monday. On 3.54.1 the same calls returned:
date('2026-01-01','end of year') 2026-12-31
date('2026-01-01','end of year','weekday -5') 2026-12-25
date('2026-02-15','end of month') 2026-02-28
datetime('2026-06-15 13:45:00','end of day') 2026-06-15 23:59:59
date('2026-10-26','weekday -5') 2026-10-23
strftime('%w','2026-12-25') was 5, Friday, which is the last Friday of 2026 and the example in the date-function documentation. strftime('%w','2026-12-31') was 4. February 2026 is not a leap year, and end of month landed on the 28th rather than a fixed 30-day offset. A modifier with two spaces, end of year, still returned no value on 3.54.1. The new modifiers are exact, not a fuzzy match.
The sign on weekday is not a spelling variant. On 3.54.1, weekday 0 from Monday 26 October 2026 moved forward to Sunday 1 November. weekday -0 moved backward to Sunday 25 October. weekday -1 stayed on 26 October, because that date was already Monday. A script edited from weekday 0 to weekday -0 does not mean “the Sunday I already meant.”
format() and printf() grew JSON conversions that 3.46.1 does not have. format('%j', 'a"b') and format('%J', 'a"b') returned no value on 3.46.1. On 3.54.1 they returned a\"b and "a\"b". %j is the escaped content. %J adds the quotes. format('%q', 'a"b') returned a"b on both shells. %q is still the SQL-string quoting conversion, not a JSON conversion. A report query that switches to %j and is run on the older shell will silently print an empty field.
Columnar modes now print a header when the query returns no rows. SELECT 1 AS n WHERE 0 in .mode box printed nothing on 3.46.1. On 3.54.1 it printed a header and no data row:
╭───╮
│ n │
╘═══╛
.mode column, .mode table, and .mode markdown did the same kind of thing: a header, and no values. .mode list and .mode csv still printed nothing on both shells. Setting .headers on after .mode box removed that empty-result header on 3.54.1 in this run. The new header is the default columnar mode, not the .headers on setting. A scraper that treats any output as “at least one row” will now see a header-only table. A scraper that matches the older box-drawing characters will also miss. A nonempty .mode box result used ┌ and └ on 3.46.1 and ╭ and ╰ on 3.54.1.
.diskused is a new dot-command, not a reason to ignore the schema gate. On 3.46.1 it was unknown command or invalid arguments: "diskused". On 3.54.1, against an empty memory database, it printed empty database. Against a file with one small table it printed the space report, including a page size of 4096. SELECT diskused('main') printed that same kind of report. SELECT diskused() failed with wrong number of arguments. SELECT diskused('t') printed no such database, because the argument is a schema name, not a table name. The tools archive still contains sqlite3_analyzer. The 3.54.0 notes deprecate that utility. They do not remove the file from the 3.54.1 zip that was unpacked here.
Verify the binary that will run
Point the check at the shell the migration job executes. A package list on another host is not that shell.
sqlite3 --version
sqlite3 -batch :memory: "SELECT sqlite_version(); SELECT sqlite_source_id();"
Then run the refusal, the terminator, and one new modifier against that same binary. A memory database is enough for the refusal. Use a copy of a real file only for the rebuild.
CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT);
ALTER TABLE t ADD COLUMN rowid TEXT;
Success, followed by a schema that contains a text column named rowid, means this shell will write the collision. The error cannot add rowid alias: rowid means this shell will not. no such column is the wrong message for this probe. It means the column was never added, not that the refusal exists.
Feed the terminator as two statements, not one:
SELECT 42
go
SELECT 7;
Two numbers means the old terminator is still active. A syntax error that shows SELECT 42 go SELECT 7 means it is not. Repeat the input with / on its own line. Then:
SELECT date('2026-01-01','end of year','weekday -5');
2026-12-25 means the new modifiers are present. An empty result means they are not. Do not use weekday 1 on a Monday as that test. Both shells returned the input date for that call.
If the job’s file already has the text column, do not plan on DROP COLUMN. Copy the file, rebuild the copy with an explicit column list, and confirm typeof(rowid) is integer if the bare name must mean the alias again. Confirm the source file was not modified. Then run the migration on the shell whose source id you recorded, not on the shell that happened to be first on PATH.