Chapter 03 · SQLite Schema Objects, ROWID, Keys, and Table Design

ROWID Tables and INTEGER PRIMARY KEY from First Principles

Investigate SQLite rowid tables experimentally, including INTEGER PRIMARY KEY aliasing, automatic rowid allocation, name shadowing, and AUTOINCREMENT.

Beginner80–100 minutesROWID experiments + key labSQLite 3.53.4 baselineLast reviewed: August 2026

Learning outcomes

Most ordinary SQLite tables are rowid tables. That phrase describes physical identity, not a column you necessarily declared. Every row in a rowid table has a unique non-NULL signed 64-bit integer key used by SQLite's table b-tree. This lesson makes that rule observable and then connects it to the special INTEGER PRIMARY KEY declaration.

01

Explain the hidden rowid of an ordinary SQLite table and its 64-bit signed-integer range.

02

Prove when INTEGER PRIMARY KEY aliases the rowid and when similar-looking declarations do not.

03

Understand rowid, _rowid_, and oid name-shadowing caveats.

04

Predict normal automatic rowid allocation without assuming IDs are gap-free or permanently increasing.

05

Explain the narrow guarantee added by AUTOINCREMENT and why its overhead is usually unnecessary.

A rowid table has an internal integer key even if you never declared one

SQLite defines a rowid table as a table that is neither virtual nor declared WITHOUT ROWID. Most tables you create with ordinary CREATE TABLE syntax therefore have a rowid. The rowid is the key used to locate the row in the underlying table b-tree.

sql · reveal the internal key
DROP TABLE IF EXISTS observation;CREATE TABLE observation (    label TEXT NOT NULL);INSERT INTO observation(label)VALUES ('alpha'), ('beta'), ('gamma');SELECT rowid, _rowid_, oid, labelFROM observationORDER BY rowid;

With a new table and ordinary inserts, you will typically see rowids 1, 2, and 3. The important rule is not that numbering “starts at one”; the rule is that each current row has a unique signed 64-bit integer rowid. Applications should not build business meaning around incidental numbers.

Persistence caveat

If a rowid table does not alias its rowid with an INTEGER PRIMARY KEY, SQLite documentation warns that raw rowids are not necessarily persistent across operations such as VACUUM. Use a named key for application identity.

Exactly INTEGER PRIMARY KEY creates a rowid alias

In an ordinary rowid table, a single-column primary key whose declared type is exactly INTEGER becomes another name for the rowid. Case does not matter, but spelling does. INT PRIMARY KEY, BIGINT PRIMARY KEY, and other integer-affinity names are ordinary columns backed by uniqueness machinery; they are not rowid aliases.

sql · four names, one integer key
DROP TABLE IF EXISTS ipk_demo;CREATE TABLE ipk_demo (    event_id INTEGER PRIMARY KEY,    message  TEXT NOT NULL);INSERT INTO ipk_demo(message) VALUES ('opened');INSERT INTO ipk_demo(event_id, message) VALUES (50, 'manual id');SELECT event_id, rowid, _rowid_, oid, messageFROM ipk_demoORDER BY event_id;

For every row, event_id and the three built-in rowid aliases refer to the same value. An insert of SQL NULL into the integer primary-key position also asks SQLite to allocate a rowid automatically.

Declaration in an ordinary rowid tableRowid alias?Practical consequence
id INTEGER PRIMARY KEYYesThe named column is the b-tree row key; no separate PK index is needed.
id INT PRIMARY KEYNoThe table still has a separate hidden rowid; the declared PK is enforced separately.
code TEXT PRIMARY KEYNoThe table is rowid-organized and normally has uniqueness index machinery for code.
PRIMARY KEY(a,b)NoComposite key is not a rowid alias in an ordinary table.

Inspect physical consequences instead of memorizing slogans

Use PRAGMA index_list to see whether SQLite created an automatic index for a primary-key constraint. The exact INTEGER PRIMARY KEY case does not need one because the rowid itself enforces identity.

sql · compare key implementation
DROP TABLE IF EXISTS exact_integer;DROP TABLE IF EXISTS merely_int;CREATE TABLE exact_integer (    id INTEGER PRIMARY KEY,    payload TEXT);CREATE TABLE merely_int (    id INT PRIMARY KEY,    payload TEXT);PRAGMA index_list('exact_integer');PRAGMA index_list('merely_int');SELECT type, name, tbl_name, sqlFROM sqlite_schemaWHERE tbl_name IN ('exact_integer','merely_int')ORDER BY tbl_name, type, name;

The merely_int table should expose an automatic uniqueness index for its primary key, while exact_integer does not need a separate primary-key index. This is why “PRIMARY KEY always means a separate index” is an inaccurate mental model for SQLite.

Historical corner case

The inline declaration x INTEGER PRIMARY KEY DESC is a compatibility quirk and does not become a rowid alias. Prefer ordinary INTEGER PRIMARY KEY declarations unless you have a specific, tested reason to depend on obscure grammar behavior.

rowid, _rowid_, and oid can be shadowed by declared columns

The built-in names rowid, _rowid_, and oid work only when a declared column has not taken that same name. This makes clever schemas fragile.

sql · name shadowing
DROP TABLE IF EXISTS shadow_demo;CREATE TABLE shadow_demo (    rowid   TEXT,    payload TEXT);INSERT INTO shadow_demo(rowid, payload)VALUES ('business-row-A', 'sample');SELECT rowid, _rowid_, oid, payloadFROM shadow_demo;

Here, rowid refers to the text column you declared, while _rowid_ and oid still expose the internal integer. If you declare all three special names, none of those spellings can reach the hidden rowid. Production judgment: do not choose these names for ordinary application columns.

Normal automatic allocation is convenient, not a business sequence

When an insert omits the rowid or supplies NULL to an INTEGER PRIMARY KEY, SQLite normally chooses an unused integer, usually one more than the largest current positive rowid. If the maximum possible signed 64-bit value has been used, the algorithm can search for another unused positive value instead.

sql · ordinary rowid reuse experiment
DROP TABLE IF EXISTS normal_ids;CREATE TABLE normal_ids (    id INTEGER PRIMARY KEY,    note TEXT);INSERT INTO normal_ids(note) VALUES ('one'), ('two'), ('three');DELETE FROM normal_ids WHERE id=3;INSERT INTO normal_ids(note) VALUES ('after deleting current maximum');SELECT id, note FROM normal_ids ORDER BY id;

On a fresh table, the new row normally receives 3 again because the largest current rowid after the delete is 2. That behavior is valid and efficient. It also proves that an automatically generated integer key is not automatically a never-reused audit sequence.

Do not assumeWhy
IDs are gap-freeFailed transactions, explicit values, deletes, and conflict handling can leave gaps.
IDs always increase foreverOrdinary allocation can reuse a deleted maximum rowid.
The number encodes creation timeManual inserts and rowid reuse break that interpretation.
A higher ID means a more important objectThe key is identity, not business rank or meaning.

AUTOINCREMENT changes one guarantee and adds work

SQLite's AUTOINCREMENT keyword is narrower than similarly named features in some server databases. It is allowed only with INTEGER PRIMARY KEY on ordinary rowid tables. It changes automatic rowid selection so previously used committed rowids are not reused automatically. SQLite tracks the historical high-water mark in an internal table named sqlite_sequence.

sql · AUTOINCREMENT high-water mark
DROP TABLE IF EXISTS never_reuse_demo;CREATE TABLE never_reuse_demo (    id INTEGER PRIMARY KEY AUTOINCREMENT,    note TEXT);INSERT INTO never_reuse_demo(note) VALUES ('one'), ('two'), ('three');DELETE FROM never_reuse_demo WHERE id=3;INSERT INTO never_reuse_demo(note) VALUES ('after deleting 3');SELECT id, note FROM never_reuse_demo ORDER BY id;SELECT name, seq FROM sqlite_sequenceWHERE name='never_reuse_demo';

On a fresh database, the replacement row should receive 4 rather than 3. This is not a promise of consecutive values—gaps can still occur. The stronger non-reuse bookkeeping costs CPU, memory, disk space, and I/O, so official SQLite guidance says to avoid AUTOINCREMENT unless you specifically need its guarantee.

Production judgment

Use plain INTEGER PRIMARY KEY for ordinary surrogate keys. Add AUTOINCREMENT only when automatic reuse of a previously committed rowid would violate an external protocol, audit contract, or other explicit requirement.

Lab: prove the rules with one disposable database

Use chapter03_rowid.db. Run each experiment, predict the result first, then inspect it.

text · rowid proof lab
sqlite3 chapter03_rowid.dbCREATE TABLE device_event (    event_id INTEGER PRIMARY KEY,    event_text TEXT NOT NULL);INSERT INTO device_event(event_text)VALUES ('registered'), ('checked'), ('serviced');SELECT event_id, rowid, _rowid_, oid, event_textFROM device_event;PRAGMA index_list('device_event');DELETE FROM device_event WHERE event_id=3;INSERT INTO device_event(event_text) VALUES ('replacement event');SELECT event_id, event_text FROM device_event ORDER BY event_id;SELECT last_insert_rowid() AS last_rowid_for_this_connection;

The four aliases should agree, index_list should not show a primary-key autoindex for the exact INTEGER PRIMARY KEY, and the deleted maximum value can be reused by ordinary allocation. last_insert_rowid() is connection state tied to a successful insert into a rowid table; Lesson 3 shows why that API concept differs for WITHOUT ROWID.

ROWID reasoning check

Explain each result from the storage/key model.

  1. Why is INT PRIMARY KEY not equivalent to INTEGER PRIMARY KEY in SQLite?
  2. Why can an ordinary generated ID be reused after deleting the current maximum?
  3. What does AUTOINCREMENT guarantee that plain INTEGER PRIMARY KEY does not?
  4. Why should an application avoid using undeclared raw rowid as durable identity?
  5. What happens if a table declares a column literally named rowid?
Review the answers

Only the exact INTEGER declaration activates the rowid alias rule; ordinary allocation normally uses a value above the largest current rowid and therefore can reuse a removed maximum; AUTOINCREMENT prevents automatic reuse of previously committed rowids by tracking a high-water mark; undeclared rowids can change in cases such as VACUUM; and a declared rowid column shadows that built-in spelling.

Failure patterns and safe corrections

FailureWhy it happensSafer design
Added AUTOINCREMENT “because every ID should auto-fill.”Plain INTEGER PRIMARY KEY already auto-allocates when omitted/NULL.Use AUTOINCREMENT only for its non-reuse guarantee.
Used BIGINT PRIMARY KEY expecting rowid aliasing.Only declared type exactly INTEGER gets the special alias behavior.Use INTEGER PRIMARY KEY when you want a named rowid alias.
Exposed raw rowid as permanent API identity.Unaliased rowids are implementation identity and may change.Declare a named application key.
Assumed primary key always means a physical secondary index.INTEGER PRIMARY KEY is the table key itself in a rowid table.Inspect PRAGMA index_list and understand the table form.
Stored business meaning in sequential IDs.Allocation behavior is not a business chronology contract.Store explicit timestamps/status/history fields for business meaning.

Summary and bridge to WITHOUT ROWID

An ordinary SQLite table is normally organized by an internal signed 64-bit rowid. INTEGER PRIMARY KEY gives that key a stable declared name and usually needs no separate primary-key index. Other primary-key forms are different: in a rowid table they typically sit alongside the hidden rowid. Lesson 3 asks when a table whose real identity is a text or composite key should remove that hidden rowid entirely.

References

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.