Every Table Has a Secret Column
Create a plain SQLite table and you've already got one column you didn't declare:
That rowid column is real. SQLite assigns one to every row of every ordinary table, whether you ask for it or not. It's a 64-bit signed integer, unique within the table, and it's the actual key SQLite uses to find rows in its B-tree storage. Think of it as the table's spine — the index that keeps everything else organised.
You usually don't see it because SELECT * doesn't include it. You have to ask for it by name.
ROWID Has Three Aliases
Because rowid shows up so often in SQL written for other databases, SQLite accepts three names for the same column:
rowid, oid, and _rowid_ all refer to the same hidden column. If you've declared a real column with one of those names, your column wins and the alias becomes unavailable — but that's the only catch. In day-to-day code, just write rowid.
INTEGER PRIMARY KEY Is the Magic Phrase
Here's the part that trips up everyone coming from other databases. If you declare a column as exactly INTEGER PRIMARY KEY, that column doesn't get stored separately — it becomes the rowid:
rowid and id are the same column under two names. Inserts that omit id get an automatically chosen integer (typically max rowid + 1). This is why INTEGER PRIMARY KEY is the most efficient way to give a table an auto-incrementing key in SQLite — there's no extra column, no extra index, just the rowid itself.
The exact spelling matters. INT PRIMARY KEY is not the same — INT and INTEGER have different effects here:
In table a, id and rowid match. In table b, id is a normal column and rowid is the separate hidden integer. Worse, b.id doesn't auto-fill on insert — it's NULL until you set it. Stick with INTEGER PRIMARY KEY (the full word) when you want the alias behaviour.
Getting the ROWID of an Insert
After an INSERT, you often want to know the rowid that was just assigned — typically to link a child row to it. SQLite gives you last_insert_rowid():
The function returns the rowid of the most recent successful insert on the current connection. Most database drivers expose the same value as cursor.lastrowid or similar. The RETURNING clause (covered later) is another way to get it back as part of the insert itself.
ROWIDs Aren't Permanent
A row's rowid is stable while the row exists, but it's not a lifetime identifier. A VACUUM can renumber rowids, and if you delete a row the number can be reused by a future insert:
Notice the new row may or may not reuse the old rowid depending on version and circumstance — the point is you can't rely on it being unique forever. If you need an identifier that survives across deletes, vacuums, and exports, declare your own INTEGER PRIMARY KEY column (which fixes the value to that row) and consider the AUTOINCREMENT keyword if you specifically need monotonically increasing values that are never reused.
WITHOUT ROWID Tables
Sometimes the rowid is overhead you don't want — most often when your real key isn't an integer. A table of cities keyed by name, for example, ends up with two structures: the rowid B-tree, and a separate index on name to enforce the primary key. WITHOUT ROWID collapses those into one:
Now name is the actual storage key. Lookups by name skip a level of indirection, and the table is smaller. The trade-offs:
- No
rowid,oid, or_rowid_— those columns simply don't exist. last_insert_rowid()doesn't update for inserts on this table.- Incremental BLOB I/O and a few replication features aren't available.
- The table must have a
PRIMARY KEYdeclared.
WITHOUT ROWID is a measured optimisation, not a default. Reach for it when the primary key is non-integer and the table is large or write-heavy. For ordinary integer-keyed tables, the regular rowid layout is already optimal.
The Mental Model
Stripped to essentials:
- Every ordinary SQLite table has a hidden 64-bit integer key called
rowid. INTEGER PRIMARY KEY(exact spelling) makes your column an alias for it.- Use
last_insert_rowid()to read what was just assigned. - Rowids can be reused after deletes and renumbered by
VACUUM. WITHOUT ROWIDtables drop the hidden key and use your declared primary key directly — useful for non-integer keys, but they give up some features.
Most of the time you don't think about rowid at all. You declare id INTEGER PRIMARY KEY, let SQLite handle the numbering, and move on. The mechanics matter when you're tuning storage, reading existing schemas, or wondering why INT PRIMARY KEY behaves differently from INTEGER PRIMARY KEY.
Next: NOT NULL and DEFAULT
Now that the row's identity is settled, the next layer is making sure the rest of the columns hold sensible values. NOT NULL and DEFAULT are the two clauses that do most of that work — they're up next.
Frequently Asked Questions
What is ROWID in SQLite?
Every ordinary SQLite table has a hidden 64-bit signed integer column called rowid that uniquely identifies each row. SQLite uses it internally as the actual key in its B-tree storage. You can read it with SELECT rowid, * FROM t even though you never declared it.
What's the difference between ROWID and a PRIMARY KEY in SQLite?
rowid is always there; a primary key is something you declare. The special case is INTEGER PRIMARY KEY — that column becomes an alias for rowid rather than a separate column. Any other primary key (text, composite, or INT PRIMARY KEY without the full INTEGER) is stored alongside rowid, not as it.
What does WITHOUT ROWID do in SQLite?
WITHOUT ROWID tells SQLite to skip the hidden rowid and use your declared PRIMARY KEY as the actual storage key. It can save space and speed up lookups for tables with non-integer keys, but it disables some features like last_insert_rowid() and incremental BLOB I/O. Use it deliberately, not by default.